diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index cd3425efae..50efb0bf0a 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -6040,7 +6040,7 @@ export const requiredContentRules = [ message: 'missing remote chat history conversion regression', }, { - regex: /\bcore_service_agent_runtime_owner_skips_in_progress_remote_assistant_history\b/, + regex: /\bcore_service_agent_runtime_owner_preserves_in_progress_remote_assistant_history\b/, message: 'missing in-progress remote assistant history regression', }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index cd9b259d26..ee8b88564a 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -3045,7 +3045,7 @@ export function runManifestParserSelfTest({ 'core_service_agent_runtime_owner_normalizes_remote_session_model_ids', 'core_service_agent_runtime_owner_normalizes_remote_model_selection_aliases', 'core_service_agent_runtime_owner_preserves_remote_chat_history_shape', - 'core_service_agent_runtime_owner_skips_in_progress_remote_assistant_history', + 'core_service_agent_runtime_owner_preserves_in_progress_remote_assistant_history', 'core_service_agent_runtime_owner_maps_image_context_to_lifecycle_attachment', 'core_service_agent_runtime_owner_keeps_scheduler_lifecycle_port_contracts', ], diff --git a/src/apps/mobile/harmonyos/entry/build-profile.json5 b/src/apps/mobile/harmonyos/entry/build-profile.json5 index 6bd6457a2f..701c3dad01 100644 --- a/src/apps/mobile/harmonyos/entry/build-profile.json5 +++ b/src/apps/mobile/harmonyos/entry/build-profile.json5 @@ -1,6 +1,9 @@ { "apiType": "stageMode", "buildOption": { + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt" + }, "resOptions": { "copyCodeResource": { "enable": false @@ -30,4 +33,4 @@ "name": "ohosTest", } ] -} \ No newline at end of file +} diff --git a/src/apps/mobile/harmonyos/entry/oh-package.json5 b/src/apps/mobile/harmonyos/entry/oh-package.json5 index 9f79a8e578..bec4a30191 100644 --- a/src/apps/mobile/harmonyos/entry/oh-package.json5 +++ b/src/apps/mobile/harmonyos/entry/oh-package.json5 @@ -5,5 +5,7 @@ "main": "", "author": "", "license": "", - "dependencies": {} + "dependencies": {}, + "devDependencies": {}, + "dynamicDependencies": {} } diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/CMakeLists.txt b/src/apps/mobile/harmonyos/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..4f2feee565 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.5.0) +project(bitfun_crypto) + +add_library(bitfun_crypto SHARED + napi_argon2.cpp + argon2/argon2.c + argon2/core.c + argon2/encoding.c + argon2/ref.c + argon2/run.c + argon2/thread.c + argon2/blake2/blake2b.c +) + +target_include_directories(bitfun_crypto PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/argon2) +target_compile_definitions(bitfun_crypto PRIVATE A2_VISCTL) +target_link_libraries(bitfun_crypto PUBLIC libace_napi.z.so) diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/argon2.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/argon2.c new file mode 100644 index 0000000000..34da3d6b4a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/argon2.c @@ -0,0 +1,452 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "argon2.h" +#include "encoding.h" +#include "core.h" + +const char *argon2_type2string(argon2_type type, int uppercase) { + switch (type) { + case Argon2_d: + return uppercase ? "Argon2d" : "argon2d"; + case Argon2_i: + return uppercase ? "Argon2i" : "argon2i"; + case Argon2_id: + return uppercase ? "Argon2id" : "argon2id"; + } + + return NULL; +} + +int argon2_ctx(argon2_context *context, argon2_type type) { + /* 1. Validate all inputs */ + int result = validate_inputs(context); + uint32_t memory_blocks, segment_length; + argon2_instance_t instance; + + if (ARGON2_OK != result) { + return result; + } + + if (Argon2_d != type && Argon2_i != type && Argon2_id != type) { + return ARGON2_INCORRECT_TYPE; + } + + /* 2. Align memory size */ + /* Minimum memory_blocks = 8L blocks, where L is the number of lanes */ + memory_blocks = context->m_cost; + + if (memory_blocks < 2 * ARGON2_SYNC_POINTS * context->lanes) { + memory_blocks = 2 * ARGON2_SYNC_POINTS * context->lanes; + } + + segment_length = memory_blocks / (context->lanes * ARGON2_SYNC_POINTS); + /* Ensure that all segments have equal length */ + memory_blocks = segment_length * (context->lanes * ARGON2_SYNC_POINTS); + + instance.version = context->version; + instance.memory = NULL; + instance.passes = context->t_cost; + instance.memory_blocks = memory_blocks; + instance.segment_length = segment_length; + instance.lane_length = segment_length * ARGON2_SYNC_POINTS; + instance.lanes = context->lanes; + instance.threads = context->threads; + instance.type = type; + + if (instance.threads > instance.lanes) { + instance.threads = instance.lanes; + } + + /* 3. Initialization: Hashing inputs, allocating memory, filling first + * blocks + */ + result = initialize(&instance, context); + + if (ARGON2_OK != result) { + return result; + } + + /* 4. Filling memory */ + result = fill_memory_blocks(&instance); + + if (ARGON2_OK != result) { + return result; + } + /* 5. Finalization */ + finalize(context, &instance); + + return ARGON2_OK; +} + +int argon2_hash(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, const size_t saltlen, + void *hash, const size_t hashlen, char *encoded, + const size_t encodedlen, argon2_type type, + const uint32_t version){ + + argon2_context context; + int result; + uint8_t *out; + + if (pwdlen > ARGON2_MAX_PWD_LENGTH) { + return ARGON2_PWD_TOO_LONG; + } + + if (saltlen > ARGON2_MAX_SALT_LENGTH) { + return ARGON2_SALT_TOO_LONG; + } + + if (hashlen > ARGON2_MAX_OUTLEN) { + return ARGON2_OUTPUT_TOO_LONG; + } + + if (hashlen < ARGON2_MIN_OUTLEN) { + return ARGON2_OUTPUT_TOO_SHORT; + } + + out = malloc(hashlen); + if (!out) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + context.out = (uint8_t *)out; + context.outlen = (uint32_t)hashlen; + context.pwd = CONST_CAST(uint8_t *)pwd; + context.pwdlen = (uint32_t)pwdlen; + context.salt = CONST_CAST(uint8_t *)salt; + context.saltlen = (uint32_t)saltlen; + context.secret = NULL; + context.secretlen = 0; + context.ad = NULL; + context.adlen = 0; + context.t_cost = t_cost; + context.m_cost = m_cost; + context.lanes = parallelism; + context.threads = parallelism; + context.allocate_cbk = NULL; + context.free_cbk = NULL; + context.flags = ARGON2_DEFAULT_FLAGS; + context.version = version; + + result = argon2_ctx(&context, type); + + if (result != ARGON2_OK) { + clear_internal_memory(out, hashlen); + free(out); + return result; + } + + /* if raw hash requested, write it */ + if (hash) { + memcpy(hash, out, hashlen); + } + + /* if encoding requested, write it */ + if (encoded && encodedlen) { + if (encode_string(encoded, encodedlen, &context, type) != ARGON2_OK) { + clear_internal_memory(out, hashlen); /* wipe buffers if error */ + clear_internal_memory(encoded, encodedlen); + free(out); + return ARGON2_ENCODING_FAIL; + } + } + clear_internal_memory(out, hashlen); + free(out); + + return ARGON2_OK; +} + +int argon2i_hash_encoded(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, const size_t hashlen, + char *encoded, const size_t encodedlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + NULL, hashlen, encoded, encodedlen, Argon2_i, + ARGON2_VERSION_NUMBER); +} + +int argon2i_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, const size_t hashlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + hash, hashlen, NULL, 0, Argon2_i, ARGON2_VERSION_NUMBER); +} + +int argon2d_hash_encoded(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, const size_t hashlen, + char *encoded, const size_t encodedlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + NULL, hashlen, encoded, encodedlen, Argon2_d, + ARGON2_VERSION_NUMBER); +} + +int argon2d_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, const size_t hashlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + hash, hashlen, NULL, 0, Argon2_d, ARGON2_VERSION_NUMBER); +} + +int argon2id_hash_encoded(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, const size_t hashlen, + char *encoded, const size_t encodedlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + NULL, hashlen, encoded, encodedlen, Argon2_id, + ARGON2_VERSION_NUMBER); +} + +int argon2id_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, const size_t hashlen) { + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + hash, hashlen, NULL, 0, Argon2_id, + ARGON2_VERSION_NUMBER); +} + +static int argon2_compare(const uint8_t *b1, const uint8_t *b2, size_t len) { + size_t i; + uint8_t d = 0U; + + for (i = 0U; i < len; i++) { + d |= b1[i] ^ b2[i]; + } + return (int)((1 & ((d - 1) >> 8)) - 1); +} + +int argon2_verify(const char *encoded, const void *pwd, const size_t pwdlen, + argon2_type type) { + + argon2_context ctx; + uint8_t *desired_result = NULL; + + int ret = ARGON2_OK; + + size_t encoded_len; + uint32_t max_field_len; + + if (pwdlen > ARGON2_MAX_PWD_LENGTH) { + return ARGON2_PWD_TOO_LONG; + } + + if (encoded == NULL) { + return ARGON2_DECODING_FAIL; + } + + encoded_len = strlen(encoded); + if (encoded_len > UINT32_MAX) { + return ARGON2_DECODING_FAIL; + } + + /* No field can be longer than the encoded length */ + max_field_len = (uint32_t)encoded_len; + + ctx.saltlen = max_field_len; + ctx.outlen = max_field_len; + + ctx.salt = malloc(ctx.saltlen); + ctx.out = malloc(ctx.outlen); + if (!ctx.salt || !ctx.out) { + ret = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + ctx.pwd = (uint8_t *)pwd; + ctx.pwdlen = (uint32_t)pwdlen; + + ret = decode_string(&ctx, encoded, type); + if (ret != ARGON2_OK) { + goto fail; + } + + /* Set aside the desired result, and get a new buffer. */ + desired_result = ctx.out; + ctx.out = malloc(ctx.outlen); + if (!ctx.out) { + ret = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + ret = argon2_verify_ctx(&ctx, (char *)desired_result, type); + if (ret != ARGON2_OK) { + goto fail; + } + +fail: + free(ctx.salt); + free(ctx.out); + free(desired_result); + + return ret; +} + +int argon2i_verify(const char *encoded, const void *pwd, const size_t pwdlen) { + + return argon2_verify(encoded, pwd, pwdlen, Argon2_i); +} + +int argon2d_verify(const char *encoded, const void *pwd, const size_t pwdlen) { + + return argon2_verify(encoded, pwd, pwdlen, Argon2_d); +} + +int argon2id_verify(const char *encoded, const void *pwd, const size_t pwdlen) { + + return argon2_verify(encoded, pwd, pwdlen, Argon2_id); +} + +int argon2d_ctx(argon2_context *context) { + return argon2_ctx(context, Argon2_d); +} + +int argon2i_ctx(argon2_context *context) { + return argon2_ctx(context, Argon2_i); +} + +int argon2id_ctx(argon2_context *context) { + return argon2_ctx(context, Argon2_id); +} + +int argon2_verify_ctx(argon2_context *context, const char *hash, + argon2_type type) { + int ret = argon2_ctx(context, type); + if (ret != ARGON2_OK) { + return ret; + } + + if (argon2_compare((uint8_t *)hash, context->out, context->outlen)) { + return ARGON2_VERIFY_MISMATCH; + } + + return ARGON2_OK; +} + +int argon2d_verify_ctx(argon2_context *context, const char *hash) { + return argon2_verify_ctx(context, hash, Argon2_d); +} + +int argon2i_verify_ctx(argon2_context *context, const char *hash) { + return argon2_verify_ctx(context, hash, Argon2_i); +} + +int argon2id_verify_ctx(argon2_context *context, const char *hash) { + return argon2_verify_ctx(context, hash, Argon2_id); +} + +const char *argon2_error_message(int error_code) { + switch (error_code) { + case ARGON2_OK: + return "OK"; + case ARGON2_OUTPUT_PTR_NULL: + return "Output pointer is NULL"; + case ARGON2_OUTPUT_TOO_SHORT: + return "Output is too short"; + case ARGON2_OUTPUT_TOO_LONG: + return "Output is too long"; + case ARGON2_PWD_TOO_SHORT: + return "Password is too short"; + case ARGON2_PWD_TOO_LONG: + return "Password is too long"; + case ARGON2_SALT_TOO_SHORT: + return "Salt is too short"; + case ARGON2_SALT_TOO_LONG: + return "Salt is too long"; + case ARGON2_AD_TOO_SHORT: + return "Associated data is too short"; + case ARGON2_AD_TOO_LONG: + return "Associated data is too long"; + case ARGON2_SECRET_TOO_SHORT: + return "Secret is too short"; + case ARGON2_SECRET_TOO_LONG: + return "Secret is too long"; + case ARGON2_TIME_TOO_SMALL: + return "Time cost is too small"; + case ARGON2_TIME_TOO_LARGE: + return "Time cost is too large"; + case ARGON2_MEMORY_TOO_LITTLE: + return "Memory cost is too small"; + case ARGON2_MEMORY_TOO_MUCH: + return "Memory cost is too large"; + case ARGON2_LANES_TOO_FEW: + return "Too few lanes"; + case ARGON2_LANES_TOO_MANY: + return "Too many lanes"; + case ARGON2_PWD_PTR_MISMATCH: + return "Password pointer is NULL, but password length is not 0"; + case ARGON2_SALT_PTR_MISMATCH: + return "Salt pointer is NULL, but salt length is not 0"; + case ARGON2_SECRET_PTR_MISMATCH: + return "Secret pointer is NULL, but secret length is not 0"; + case ARGON2_AD_PTR_MISMATCH: + return "Associated data pointer is NULL, but ad length is not 0"; + case ARGON2_MEMORY_ALLOCATION_ERROR: + return "Memory allocation error"; + case ARGON2_FREE_MEMORY_CBK_NULL: + return "The free memory callback is NULL"; + case ARGON2_ALLOCATE_MEMORY_CBK_NULL: + return "The allocate memory callback is NULL"; + case ARGON2_INCORRECT_PARAMETER: + return "Argon2_Context context is NULL"; + case ARGON2_INCORRECT_TYPE: + return "There is no such version of Argon2"; + case ARGON2_OUT_PTR_MISMATCH: + return "Output pointer mismatch"; + case ARGON2_THREADS_TOO_FEW: + return "Not enough threads"; + case ARGON2_THREADS_TOO_MANY: + return "Too many threads"; + case ARGON2_MISSING_ARGS: + return "Missing arguments"; + case ARGON2_ENCODING_FAIL: + return "Encoding failed"; + case ARGON2_DECODING_FAIL: + return "Decoding failed"; + case ARGON2_THREAD_FAIL: + return "Threading failure"; + case ARGON2_DECODING_LENGTH_FAIL: + return "Some of encoded parameters are too long or too short"; + case ARGON2_VERIFY_MISMATCH: + return "The password does not match the supplied hash"; + default: + return "Unknown error code"; + } +} + +size_t argon2_encodedlen(uint32_t t_cost, uint32_t m_cost, uint32_t parallelism, + uint32_t saltlen, uint32_t hashlen, argon2_type type) { + return strlen("$$v=$m=,t=,p=$$") + strlen(argon2_type2string(type, 0)) + + numlen(t_cost) + numlen(m_cost) + numlen(parallelism) + + b64len(saltlen) + b64len(hashlen) + numlen(ARGON2_VERSION_NUMBER) + 1; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/argon2.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/argon2.h new file mode 100644 index 0000000000..3980bb352f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/argon2.h @@ -0,0 +1,437 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ARGON2_H +#define ARGON2_H + +#include +#include +#include + +#if defined(__cplusplus) +extern "C" { +#endif + +/* Symbols visibility control */ +#ifdef A2_VISCTL +#define ARGON2_PUBLIC __attribute__((visibility("default"))) +#define ARGON2_LOCAL __attribute__ ((visibility ("hidden"))) +#elif defined(_MSC_VER) +#define ARGON2_PUBLIC __declspec(dllexport) +#define ARGON2_LOCAL +#else +#define ARGON2_PUBLIC +#define ARGON2_LOCAL +#endif + +/* + * Argon2 input parameter restrictions + */ + +/* Minimum and maximum number of lanes (degree of parallelism) */ +#define ARGON2_MIN_LANES UINT32_C(1) +#define ARGON2_MAX_LANES UINT32_C(0xFFFFFF) + +/* Minimum and maximum number of threads */ +#define ARGON2_MIN_THREADS UINT32_C(1) +#define ARGON2_MAX_THREADS UINT32_C(0xFFFFFF) + +/* Number of synchronization points between lanes per pass */ +#define ARGON2_SYNC_POINTS UINT32_C(4) + +/* Minimum and maximum digest size in bytes */ +#define ARGON2_MIN_OUTLEN UINT32_C(4) +#define ARGON2_MAX_OUTLEN UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum number of memory blocks (each of BLOCK_SIZE bytes) */ +#define ARGON2_MIN_MEMORY (2 * ARGON2_SYNC_POINTS) /* 2 blocks per slice */ + +#define ARGON2_MIN(a, b) ((a) < (b) ? (a) : (b)) +/* Max memory size is addressing-space/2, topping at 2^32 blocks (4 TB) */ +#define ARGON2_MAX_MEMORY_BITS \ + ARGON2_MIN(UINT32_C(32), (sizeof(void *) * CHAR_BIT - 10 - 1)) +#define ARGON2_MAX_MEMORY \ + ARGON2_MIN(UINT32_C(0xFFFFFFFF), UINT64_C(1) << ARGON2_MAX_MEMORY_BITS) + +/* Minimum and maximum number of passes */ +#define ARGON2_MIN_TIME UINT32_C(1) +#define ARGON2_MAX_TIME UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum password length in bytes */ +#define ARGON2_MIN_PWD_LENGTH UINT32_C(0) +#define ARGON2_MAX_PWD_LENGTH UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum associated data length in bytes */ +#define ARGON2_MIN_AD_LENGTH UINT32_C(0) +#define ARGON2_MAX_AD_LENGTH UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum salt length in bytes */ +#define ARGON2_MIN_SALT_LENGTH UINT32_C(8) +#define ARGON2_MAX_SALT_LENGTH UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum key length in bytes */ +#define ARGON2_MIN_SECRET UINT32_C(0) +#define ARGON2_MAX_SECRET UINT32_C(0xFFFFFFFF) + +/* Flags to determine which fields are securely wiped (default = no wipe). */ +#define ARGON2_DEFAULT_FLAGS UINT32_C(0) +#define ARGON2_FLAG_CLEAR_PASSWORD (UINT32_C(1) << 0) +#define ARGON2_FLAG_CLEAR_SECRET (UINT32_C(1) << 1) + +/* Global flag to determine if we are wiping internal memory buffers. This flag + * is defined in core.c and defaults to 1 (wipe internal memory). */ +extern int FLAG_clear_internal_memory; + +/* Error codes */ +typedef enum Argon2_ErrorCodes { + ARGON2_OK = 0, + + ARGON2_OUTPUT_PTR_NULL = -1, + + ARGON2_OUTPUT_TOO_SHORT = -2, + ARGON2_OUTPUT_TOO_LONG = -3, + + ARGON2_PWD_TOO_SHORT = -4, + ARGON2_PWD_TOO_LONG = -5, + + ARGON2_SALT_TOO_SHORT = -6, + ARGON2_SALT_TOO_LONG = -7, + + ARGON2_AD_TOO_SHORT = -8, + ARGON2_AD_TOO_LONG = -9, + + ARGON2_SECRET_TOO_SHORT = -10, + ARGON2_SECRET_TOO_LONG = -11, + + ARGON2_TIME_TOO_SMALL = -12, + ARGON2_TIME_TOO_LARGE = -13, + + ARGON2_MEMORY_TOO_LITTLE = -14, + ARGON2_MEMORY_TOO_MUCH = -15, + + ARGON2_LANES_TOO_FEW = -16, + ARGON2_LANES_TOO_MANY = -17, + + ARGON2_PWD_PTR_MISMATCH = -18, /* NULL ptr with non-zero length */ + ARGON2_SALT_PTR_MISMATCH = -19, /* NULL ptr with non-zero length */ + ARGON2_SECRET_PTR_MISMATCH = -20, /* NULL ptr with non-zero length */ + ARGON2_AD_PTR_MISMATCH = -21, /* NULL ptr with non-zero length */ + + ARGON2_MEMORY_ALLOCATION_ERROR = -22, + + ARGON2_FREE_MEMORY_CBK_NULL = -23, + ARGON2_ALLOCATE_MEMORY_CBK_NULL = -24, + + ARGON2_INCORRECT_PARAMETER = -25, + ARGON2_INCORRECT_TYPE = -26, + + ARGON2_OUT_PTR_MISMATCH = -27, + + ARGON2_THREADS_TOO_FEW = -28, + ARGON2_THREADS_TOO_MANY = -29, + + ARGON2_MISSING_ARGS = -30, + + ARGON2_ENCODING_FAIL = -31, + + ARGON2_DECODING_FAIL = -32, + + ARGON2_THREAD_FAIL = -33, + + ARGON2_DECODING_LENGTH_FAIL = -34, + + ARGON2_VERIFY_MISMATCH = -35 +} argon2_error_codes; + +/* Memory allocator types --- for external allocation */ +typedef int (*allocate_fptr)(uint8_t **memory, size_t bytes_to_allocate); +typedef void (*deallocate_fptr)(uint8_t *memory, size_t bytes_to_allocate); + +/* Argon2 external data structures */ + +/* + ***** + * Context: structure to hold Argon2 inputs: + * output array and its length, + * password and its length, + * salt and its length, + * secret and its length, + * associated data and its length, + * number of passes, amount of used memory (in KBytes, can be rounded up a bit) + * number of parallel threads that will be run. + * All the parameters above affect the output hash value. + * Additionally, two function pointers can be provided to allocate and + * deallocate the memory (if NULL, memory will be allocated internally). + * Also, three flags indicate whether to erase password, secret as soon as they + * are pre-hashed (and thus not needed anymore), and the entire memory + ***** + * Simplest situation: you have output array out[8], password is stored in + * pwd[32], salt is stored in salt[16], you do not have keys nor associated + * data. You need to spend 1 GB of RAM and you run 5 passes of Argon2d with + * 4 parallel lanes. + * You want to erase the password, but you're OK with last pass not being + * erased. You want to use the default memory allocator. + * Then you initialize: + Argon2_Context(out,8,pwd,32,salt,16,NULL,0,NULL,0,5,1<<20,4,4,NULL,NULL,true,false,false,false) + */ +typedef struct Argon2_Context { + uint8_t *out; /* output array */ + uint32_t outlen; /* digest length */ + + uint8_t *pwd; /* password array */ + uint32_t pwdlen; /* password length */ + + uint8_t *salt; /* salt array */ + uint32_t saltlen; /* salt length */ + + uint8_t *secret; /* key array */ + uint32_t secretlen; /* key length */ + + uint8_t *ad; /* associated data array */ + uint32_t adlen; /* associated data length */ + + uint32_t t_cost; /* number of passes */ + uint32_t m_cost; /* amount of memory requested (KB) */ + uint32_t lanes; /* number of lanes */ + uint32_t threads; /* maximum number of threads */ + + uint32_t version; /* version number */ + + allocate_fptr allocate_cbk; /* pointer to memory allocator */ + deallocate_fptr free_cbk; /* pointer to memory deallocator */ + + uint32_t flags; /* array of bool options */ +} argon2_context; + +/* Argon2 primitive type */ +typedef enum Argon2_type { + Argon2_d = 0, + Argon2_i = 1, + Argon2_id = 2 +} argon2_type; + +/* Version of the algorithm */ +typedef enum Argon2_version { + ARGON2_VERSION_10 = 0x10, + ARGON2_VERSION_13 = 0x13, + ARGON2_VERSION_NUMBER = ARGON2_VERSION_13 +} argon2_version; + +/* + * Function that gives the string representation of an argon2_type. + * @param type The argon2_type that we want the string for + * @param uppercase Whether the string should have the first letter uppercase + * @return NULL if invalid type, otherwise the string representation. + */ +ARGON2_PUBLIC const char *argon2_type2string(argon2_type type, int uppercase); + +/* + * Function that performs memory-hard hashing with certain degree of parallelism + * @param context Pointer to the Argon2 internal structure + * @return Error code if smth is wrong, ARGON2_OK otherwise + */ +ARGON2_PUBLIC int argon2_ctx(argon2_context *context, argon2_type type); + +/** + * Hashes a password with Argon2i, producing an encoded hash + * @param t_cost Number of iterations + * @param m_cost Sets memory usage to m_cost kibibytes + * @param parallelism Number of threads and compute lanes + * @param pwd Pointer to password + * @param pwdlen Password size in bytes + * @param salt Pointer to salt + * @param saltlen Salt size in bytes + * @param hashlen Desired length of the hash in bytes + * @param encoded Buffer where to write the encoded hash + * @param encodedlen Size of the buffer (thus max size of the encoded hash) + * @pre Different parallelism levels will give different results + * @pre Returns ARGON2_OK if successful + */ +ARGON2_PUBLIC int argon2i_hash_encoded(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, + const void *pwd, const size_t pwdlen, + const void *salt, const size_t saltlen, + const size_t hashlen, char *encoded, + const size_t encodedlen); + +/** + * Hashes a password with Argon2i, producing a raw hash at @hash + * @param t_cost Number of iterations + * @param m_cost Sets memory usage to m_cost kibibytes + * @param parallelism Number of threads and compute lanes + * @param pwd Pointer to password + * @param pwdlen Password size in bytes + * @param salt Pointer to salt + * @param saltlen Salt size in bytes + * @param hash Buffer where to write the raw hash - updated by the function + * @param hashlen Desired length of the hash in bytes + * @pre Different parallelism levels will give different results + * @pre Returns ARGON2_OK if successful + */ +ARGON2_PUBLIC int argon2i_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen); + +ARGON2_PUBLIC int argon2d_hash_encoded(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, + const void *pwd, const size_t pwdlen, + const void *salt, const size_t saltlen, + const size_t hashlen, char *encoded, + const size_t encodedlen); + +ARGON2_PUBLIC int argon2d_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen); + +ARGON2_PUBLIC int argon2id_hash_encoded(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, + const void *pwd, const size_t pwdlen, + const void *salt, const size_t saltlen, + const size_t hashlen, char *encoded, + const size_t encodedlen); + +ARGON2_PUBLIC int argon2id_hash_raw(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen); + +/* generic function underlying the above ones */ +ARGON2_PUBLIC int argon2_hash(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen, char *encoded, + const size_t encodedlen, argon2_type type, + const uint32_t version); + +/** + * Verifies a password against an encoded string + * Encoded string is restricted as in validate_inputs() + * @param encoded String encoding parameters, salt, hash + * @param pwd Pointer to password + * @pre Returns ARGON2_OK if successful + */ +ARGON2_PUBLIC int argon2i_verify(const char *encoded, const void *pwd, + const size_t pwdlen); + +ARGON2_PUBLIC int argon2d_verify(const char *encoded, const void *pwd, + const size_t pwdlen); + +ARGON2_PUBLIC int argon2id_verify(const char *encoded, const void *pwd, + const size_t pwdlen); + +/* generic function underlying the above ones */ +ARGON2_PUBLIC int argon2_verify(const char *encoded, const void *pwd, + const size_t pwdlen, argon2_type type); + +/** + * Argon2d: Version of Argon2 that picks memory blocks depending + * on the password and salt. Only for side-channel-free + * environment!! + ***** + * @param context Pointer to current Argon2 context + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2d_ctx(argon2_context *context); + +/** + * Argon2i: Version of Argon2 that picks memory blocks + * independent on the password and salt. Good for side-channels, + * but worse w.r.t. tradeoff attacks if only one pass is used. + ***** + * @param context Pointer to current Argon2 context + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2i_ctx(argon2_context *context); + +/** + * Argon2id: Version of Argon2 where the first half-pass over memory is + * password-independent, the rest are password-dependent (on the password and + * salt). OK against side channels (they reduce to 1/2-pass Argon2i), and + * better with w.r.t. tradeoff attacks (similar to Argon2d). + ***** + * @param context Pointer to current Argon2 context + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2id_ctx(argon2_context *context); + +/** + * Verify if a given password is correct for Argon2d hashing + * @param context Pointer to current Argon2 context + * @param hash The password hash to verify. The length of the hash is + * specified by the context outlen member + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2d_verify_ctx(argon2_context *context, const char *hash); + +/** + * Verify if a given password is correct for Argon2i hashing + * @param context Pointer to current Argon2 context + * @param hash The password hash to verify. The length of the hash is + * specified by the context outlen member + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2i_verify_ctx(argon2_context *context, const char *hash); + +/** + * Verify if a given password is correct for Argon2id hashing + * @param context Pointer to current Argon2 context + * @param hash The password hash to verify. The length of the hash is + * specified by the context outlen member + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2id_verify_ctx(argon2_context *context, + const char *hash); + +/* generic function underlying the above ones */ +ARGON2_PUBLIC int argon2_verify_ctx(argon2_context *context, const char *hash, + argon2_type type); + +/** + * Get the associated error message for given error code + * @return The error message associated with the given error code + */ +ARGON2_PUBLIC const char *argon2_error_message(int error_code); + +/** + * Returns the encoded hash length for the given input parameters + * @param t_cost Number of iterations + * @param m_cost Memory usage in kibibytes + * @param parallelism Number of threads; used to compute lanes + * @param saltlen Salt size in bytes + * @param hashlen Hash size in bytes + * @param type The argon2_type that we want the encoded length for + * @return The encoded hash length in bytes + */ +ARGON2_PUBLIC size_t argon2_encodedlen(uint32_t t_cost, uint32_t m_cost, + uint32_t parallelism, uint32_t saltlen, + uint32_t hashlen, argon2_type type); + +#if defined(__cplusplus) +} +#endif + +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2-impl.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2-impl.h new file mode 100644 index 0000000000..86d0d5cfa9 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2-impl.h @@ -0,0 +1,156 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef PORTABLE_BLAKE2_IMPL_H +#define PORTABLE_BLAKE2_IMPL_H + +#include +#include + +#ifdef _WIN32 +#define BLAKE2_INLINE __inline +#elif defined(__GNUC__) || defined(__clang__) +#define BLAKE2_INLINE __inline__ +#else +#define BLAKE2_INLINE +#endif + +/* Argon2 Team - Begin Code */ +/* + Not an exhaustive list, but should cover the majority of modern platforms + Additionally, the code will always be correct---this is only a performance + tweak. +*/ +#if (defined(__BYTE_ORDER__) && \ + (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__)) || \ + defined(__LITTLE_ENDIAN__) || defined(__ARMEL__) || defined(__MIPSEL__) || \ + defined(__AARCH64EL__) || defined(__amd64__) || defined(__i386__) || \ + defined(_M_IX86) || defined(_M_X64) || defined(_M_AMD64) || \ + defined(_M_ARM) +#define NATIVE_LITTLE_ENDIAN +#endif +/* Argon2 Team - End Code */ + +static BLAKE2_INLINE uint32_t load32(const void *src) { +#if defined(NATIVE_LITTLE_ENDIAN) + uint32_t w; + memcpy(&w, src, sizeof w); + return w; +#else + const uint8_t *p = (const uint8_t *)src; + uint32_t w = *p++; + w |= (uint32_t)(*p++) << 8; + w |= (uint32_t)(*p++) << 16; + w |= (uint32_t)(*p++) << 24; + return w; +#endif +} + +static BLAKE2_INLINE uint64_t load64(const void *src) { +#if defined(NATIVE_LITTLE_ENDIAN) + uint64_t w; + memcpy(&w, src, sizeof w); + return w; +#else + const uint8_t *p = (const uint8_t *)src; + uint64_t w = *p++; + w |= (uint64_t)(*p++) << 8; + w |= (uint64_t)(*p++) << 16; + w |= (uint64_t)(*p++) << 24; + w |= (uint64_t)(*p++) << 32; + w |= (uint64_t)(*p++) << 40; + w |= (uint64_t)(*p++) << 48; + w |= (uint64_t)(*p++) << 56; + return w; +#endif +} + +static BLAKE2_INLINE void store32(void *dst, uint32_t w) { +#if defined(NATIVE_LITTLE_ENDIAN) + memcpy(dst, &w, sizeof w); +#else + uint8_t *p = (uint8_t *)dst; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; +#endif +} + +static BLAKE2_INLINE void store64(void *dst, uint64_t w) { +#if defined(NATIVE_LITTLE_ENDIAN) + memcpy(dst, &w, sizeof w); +#else + uint8_t *p = (uint8_t *)dst; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; +#endif +} + +static BLAKE2_INLINE uint64_t load48(const void *src) { + const uint8_t *p = (const uint8_t *)src; + uint64_t w = *p++; + w |= (uint64_t)(*p++) << 8; + w |= (uint64_t)(*p++) << 16; + w |= (uint64_t)(*p++) << 24; + w |= (uint64_t)(*p++) << 32; + w |= (uint64_t)(*p++) << 40; + return w; +} + +static BLAKE2_INLINE void store48(void *dst, uint64_t w) { + uint8_t *p = (uint8_t *)dst; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; +} + +static BLAKE2_INLINE uint32_t rotr32(const uint32_t w, const unsigned c) { + return (w >> c) | (w << (32 - c)); +} + +static BLAKE2_INLINE uint64_t rotr64(const uint64_t w, const unsigned c) { + return (w >> c) | (w << (64 - c)); +} + +void clear_internal_memory(void *v, size_t n); + +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2.h new file mode 100644 index 0000000000..501c6a3186 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2.h @@ -0,0 +1,89 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef PORTABLE_BLAKE2_H +#define PORTABLE_BLAKE2_H + +#include + +#if defined(__cplusplus) +extern "C" { +#endif + +enum blake2b_constant { + BLAKE2B_BLOCKBYTES = 128, + BLAKE2B_OUTBYTES = 64, + BLAKE2B_KEYBYTES = 64, + BLAKE2B_SALTBYTES = 16, + BLAKE2B_PERSONALBYTES = 16 +}; + +#pragma pack(push, 1) +typedef struct __blake2b_param { + uint8_t digest_length; /* 1 */ + uint8_t key_length; /* 2 */ + uint8_t fanout; /* 3 */ + uint8_t depth; /* 4 */ + uint32_t leaf_length; /* 8 */ + uint64_t node_offset; /* 16 */ + uint8_t node_depth; /* 17 */ + uint8_t inner_length; /* 18 */ + uint8_t reserved[14]; /* 32 */ + uint8_t salt[BLAKE2B_SALTBYTES]; /* 48 */ + uint8_t personal[BLAKE2B_PERSONALBYTES]; /* 64 */ +} blake2b_param; +#pragma pack(pop) + +typedef struct __blake2b_state { + uint64_t h[8]; + uint64_t t[2]; + uint64_t f[2]; + uint8_t buf[BLAKE2B_BLOCKBYTES]; + unsigned buflen; + unsigned outlen; + uint8_t last_node; +} blake2b_state; + +/* Ensure param structs have not been wrongly padded */ +/* Poor man's static_assert */ +enum { + blake2_size_check_0 = 1 / !!(CHAR_BIT == 8), + blake2_size_check_2 = + 1 / !!(sizeof(blake2b_param) == sizeof(uint64_t) * CHAR_BIT) +}; + +/* Streaming API */ +ARGON2_LOCAL int blake2b_init(blake2b_state *S, size_t outlen); +ARGON2_LOCAL int blake2b_init_key(blake2b_state *S, size_t outlen, const void *key, + size_t keylen); +ARGON2_LOCAL int blake2b_init_param(blake2b_state *S, const blake2b_param *P); +ARGON2_LOCAL int blake2b_update(blake2b_state *S, const void *in, size_t inlen); +ARGON2_LOCAL int blake2b_final(blake2b_state *S, void *out, size_t outlen); + +/* Simple API */ +ARGON2_LOCAL int blake2b(void *out, size_t outlen, const void *in, size_t inlen, + const void *key, size_t keylen); + +/* Argon2 Team - Begin Code */ +ARGON2_LOCAL int blake2b_long(void *out, size_t outlen, const void *in, size_t inlen); +/* Argon2 Team - End Code */ + +#if defined(__cplusplus) +} +#endif + +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2b.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2b.c new file mode 100644 index 0000000000..3b519ddb4d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blake2b.c @@ -0,0 +1,390 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "blake2.h" +#include "blake2-impl.h" + +static const uint64_t blake2b_IV[8] = { + UINT64_C(0x6a09e667f3bcc908), UINT64_C(0xbb67ae8584caa73b), + UINT64_C(0x3c6ef372fe94f82b), UINT64_C(0xa54ff53a5f1d36f1), + UINT64_C(0x510e527fade682d1), UINT64_C(0x9b05688c2b3e6c1f), + UINT64_C(0x1f83d9abfb41bd6b), UINT64_C(0x5be0cd19137e2179)}; + +static const unsigned int blake2b_sigma[12][16] = { + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, + {11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4}, + {7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8}, + {9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13}, + {2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9}, + {12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11}, + {13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10}, + {6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5}, + {10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0}, + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, +}; + +static BLAKE2_INLINE void blake2b_set_lastnode(blake2b_state *S) { + S->f[1] = (uint64_t)-1; +} + +static BLAKE2_INLINE void blake2b_set_lastblock(blake2b_state *S) { + if (S->last_node) { + blake2b_set_lastnode(S); + } + S->f[0] = (uint64_t)-1; +} + +static BLAKE2_INLINE void blake2b_increment_counter(blake2b_state *S, + uint64_t inc) { + S->t[0] += inc; + S->t[1] += (S->t[0] < inc); +} + +static BLAKE2_INLINE void blake2b_invalidate_state(blake2b_state *S) { + clear_internal_memory(S, sizeof(*S)); /* wipe */ + blake2b_set_lastblock(S); /* invalidate for further use */ +} + +static BLAKE2_INLINE void blake2b_init0(blake2b_state *S) { + memset(S, 0, sizeof(*S)); + memcpy(S->h, blake2b_IV, sizeof(S->h)); +} + +int blake2b_init_param(blake2b_state *S, const blake2b_param *P) { + const unsigned char *p = (const unsigned char *)P; + unsigned int i; + + if (NULL == P || NULL == S) { + return -1; + } + + blake2b_init0(S); + /* IV XOR Parameter Block */ + for (i = 0; i < 8; ++i) { + S->h[i] ^= load64(&p[i * sizeof(S->h[i])]); + } + S->outlen = P->digest_length; + return 0; +} + +/* Sequential blake2b initialization */ +int blake2b_init(blake2b_state *S, size_t outlen) { + blake2b_param P; + + if (S == NULL) { + return -1; + } + + if ((outlen == 0) || (outlen > BLAKE2B_OUTBYTES)) { + blake2b_invalidate_state(S); + return -1; + } + + /* Setup Parameter Block for unkeyed BLAKE2 */ + P.digest_length = (uint8_t)outlen; + P.key_length = 0; + P.fanout = 1; + P.depth = 1; + P.leaf_length = 0; + P.node_offset = 0; + P.node_depth = 0; + P.inner_length = 0; + memset(P.reserved, 0, sizeof(P.reserved)); + memset(P.salt, 0, sizeof(P.salt)); + memset(P.personal, 0, sizeof(P.personal)); + + return blake2b_init_param(S, &P); +} + +int blake2b_init_key(blake2b_state *S, size_t outlen, const void *key, + size_t keylen) { + blake2b_param P; + + if (S == NULL) { + return -1; + } + + if ((outlen == 0) || (outlen > BLAKE2B_OUTBYTES)) { + blake2b_invalidate_state(S); + return -1; + } + + if ((key == 0) || (keylen == 0) || (keylen > BLAKE2B_KEYBYTES)) { + blake2b_invalidate_state(S); + return -1; + } + + /* Setup Parameter Block for keyed BLAKE2 */ + P.digest_length = (uint8_t)outlen; + P.key_length = (uint8_t)keylen; + P.fanout = 1; + P.depth = 1; + P.leaf_length = 0; + P.node_offset = 0; + P.node_depth = 0; + P.inner_length = 0; + memset(P.reserved, 0, sizeof(P.reserved)); + memset(P.salt, 0, sizeof(P.salt)); + memset(P.personal, 0, sizeof(P.personal)); + + if (blake2b_init_param(S, &P) < 0) { + blake2b_invalidate_state(S); + return -1; + } + + { + uint8_t block[BLAKE2B_BLOCKBYTES]; + memset(block, 0, BLAKE2B_BLOCKBYTES); + memcpy(block, key, keylen); + blake2b_update(S, block, BLAKE2B_BLOCKBYTES); + /* Burn the key from stack */ + clear_internal_memory(block, BLAKE2B_BLOCKBYTES); + } + return 0; +} + +static void blake2b_compress(blake2b_state *S, const uint8_t *block) { + uint64_t m[16]; + uint64_t v[16]; + unsigned int i, r; + + for (i = 0; i < 16; ++i) { + m[i] = load64(block + i * sizeof(m[i])); + } + + for (i = 0; i < 8; ++i) { + v[i] = S->h[i]; + } + + v[8] = blake2b_IV[0]; + v[9] = blake2b_IV[1]; + v[10] = blake2b_IV[2]; + v[11] = blake2b_IV[3]; + v[12] = blake2b_IV[4] ^ S->t[0]; + v[13] = blake2b_IV[5] ^ S->t[1]; + v[14] = blake2b_IV[6] ^ S->f[0]; + v[15] = blake2b_IV[7] ^ S->f[1]; + +#define G(r, i, a, b, c, d) \ + do { \ + a = a + b + m[blake2b_sigma[r][2 * i + 0]]; \ + d = rotr64(d ^ a, 32); \ + c = c + d; \ + b = rotr64(b ^ c, 24); \ + a = a + b + m[blake2b_sigma[r][2 * i + 1]]; \ + d = rotr64(d ^ a, 16); \ + c = c + d; \ + b = rotr64(b ^ c, 63); \ + } while ((void)0, 0) + +#define ROUND(r) \ + do { \ + G(r, 0, v[0], v[4], v[8], v[12]); \ + G(r, 1, v[1], v[5], v[9], v[13]); \ + G(r, 2, v[2], v[6], v[10], v[14]); \ + G(r, 3, v[3], v[7], v[11], v[15]); \ + G(r, 4, v[0], v[5], v[10], v[15]); \ + G(r, 5, v[1], v[6], v[11], v[12]); \ + G(r, 6, v[2], v[7], v[8], v[13]); \ + G(r, 7, v[3], v[4], v[9], v[14]); \ + } while ((void)0, 0) + + for (r = 0; r < 12; ++r) { + ROUND(r); + } + + for (i = 0; i < 8; ++i) { + S->h[i] = S->h[i] ^ v[i] ^ v[i + 8]; + } + +#undef G +#undef ROUND +} + +int blake2b_update(blake2b_state *S, const void *in, size_t inlen) { + const uint8_t *pin = (const uint8_t *)in; + + if (inlen == 0) { + return 0; + } + + /* Sanity check */ + if (S == NULL || in == NULL) { + return -1; + } + + /* Is this a reused state? */ + if (S->f[0] != 0) { + return -1; + } + + if (S->buflen + inlen > BLAKE2B_BLOCKBYTES) { + /* Complete current block */ + size_t left = S->buflen; + size_t fill = BLAKE2B_BLOCKBYTES - left; + memcpy(&S->buf[left], pin, fill); + blake2b_increment_counter(S, BLAKE2B_BLOCKBYTES); + blake2b_compress(S, S->buf); + S->buflen = 0; + inlen -= fill; + pin += fill; + /* Avoid buffer copies when possible */ + while (inlen > BLAKE2B_BLOCKBYTES) { + blake2b_increment_counter(S, BLAKE2B_BLOCKBYTES); + blake2b_compress(S, pin); + inlen -= BLAKE2B_BLOCKBYTES; + pin += BLAKE2B_BLOCKBYTES; + } + } + memcpy(&S->buf[S->buflen], pin, inlen); + S->buflen += (unsigned int)inlen; + return 0; +} + +int blake2b_final(blake2b_state *S, void *out, size_t outlen) { + uint8_t buffer[BLAKE2B_OUTBYTES] = {0}; + unsigned int i; + + /* Sanity checks */ + if (S == NULL || out == NULL || outlen < S->outlen) { + return -1; + } + + /* Is this a reused state? */ + if (S->f[0] != 0) { + return -1; + } + + blake2b_increment_counter(S, S->buflen); + blake2b_set_lastblock(S); + memset(&S->buf[S->buflen], 0, BLAKE2B_BLOCKBYTES - S->buflen); /* Padding */ + blake2b_compress(S, S->buf); + + for (i = 0; i < 8; ++i) { /* Output full hash to temp buffer */ + store64(buffer + sizeof(S->h[i]) * i, S->h[i]); + } + + memcpy(out, buffer, S->outlen); + clear_internal_memory(buffer, sizeof(buffer)); + clear_internal_memory(S->buf, sizeof(S->buf)); + clear_internal_memory(S->h, sizeof(S->h)); + return 0; +} + +int blake2b(void *out, size_t outlen, const void *in, size_t inlen, + const void *key, size_t keylen) { + blake2b_state S; + int ret = -1; + + /* Verify parameters */ + if (NULL == in && inlen > 0) { + goto fail; + } + + if (NULL == out || outlen == 0 || outlen > BLAKE2B_OUTBYTES) { + goto fail; + } + + if ((NULL == key && keylen > 0) || keylen > BLAKE2B_KEYBYTES) { + goto fail; + } + + if (keylen > 0) { + if (blake2b_init_key(&S, outlen, key, keylen) < 0) { + goto fail; + } + } else { + if (blake2b_init(&S, outlen) < 0) { + goto fail; + } + } + + if (blake2b_update(&S, in, inlen) < 0) { + goto fail; + } + ret = blake2b_final(&S, out, outlen); + +fail: + clear_internal_memory(&S, sizeof(S)); + return ret; +} + +/* Argon2 Team - Begin Code */ +int blake2b_long(void *pout, size_t outlen, const void *in, size_t inlen) { + uint8_t *out = (uint8_t *)pout; + blake2b_state blake_state; + uint8_t outlen_bytes[sizeof(uint32_t)] = {0}; + int ret = -1; + + if (outlen > UINT32_MAX) { + goto fail; + } + + /* Ensure little-endian byte order! */ + store32(outlen_bytes, (uint32_t)outlen); + +#define TRY(statement) \ + do { \ + ret = statement; \ + if (ret < 0) { \ + goto fail; \ + } \ + } while ((void)0, 0) + + if (outlen <= BLAKE2B_OUTBYTES) { + TRY(blake2b_init(&blake_state, outlen)); + TRY(blake2b_update(&blake_state, outlen_bytes, sizeof(outlen_bytes))); + TRY(blake2b_update(&blake_state, in, inlen)); + TRY(blake2b_final(&blake_state, out, outlen)); + } else { + uint32_t toproduce; + uint8_t out_buffer[BLAKE2B_OUTBYTES]; + uint8_t in_buffer[BLAKE2B_OUTBYTES]; + TRY(blake2b_init(&blake_state, BLAKE2B_OUTBYTES)); + TRY(blake2b_update(&blake_state, outlen_bytes, sizeof(outlen_bytes))); + TRY(blake2b_update(&blake_state, in, inlen)); + TRY(blake2b_final(&blake_state, out_buffer, BLAKE2B_OUTBYTES)); + memcpy(out, out_buffer, BLAKE2B_OUTBYTES / 2); + out += BLAKE2B_OUTBYTES / 2; + toproduce = (uint32_t)outlen - BLAKE2B_OUTBYTES / 2; + + while (toproduce > BLAKE2B_OUTBYTES) { + memcpy(in_buffer, out_buffer, BLAKE2B_OUTBYTES); + TRY(blake2b(out_buffer, BLAKE2B_OUTBYTES, in_buffer, + BLAKE2B_OUTBYTES, NULL, 0)); + memcpy(out, out_buffer, BLAKE2B_OUTBYTES / 2); + out += BLAKE2B_OUTBYTES / 2; + toproduce -= BLAKE2B_OUTBYTES / 2; + } + + memcpy(in_buffer, out_buffer, BLAKE2B_OUTBYTES); + TRY(blake2b(out_buffer, toproduce, in_buffer, BLAKE2B_OUTBYTES, NULL, + 0)); + memcpy(out, out_buffer, toproduce); + } +fail: + clear_internal_memory(&blake_state, sizeof(blake_state)); + return ret; +#undef TRY +} +/* Argon2 Team - End Code */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blamka-round-opt.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blamka-round-opt.h new file mode 100644 index 0000000000..3127f2a373 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blamka-round-opt.h @@ -0,0 +1,471 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef BLAKE_ROUND_MKA_OPT_H +#define BLAKE_ROUND_MKA_OPT_H + +#include "blake2-impl.h" + +#include +#if defined(__SSSE3__) +#include /* for _mm_shuffle_epi8 and _mm_alignr_epi8 */ +#endif + +#if defined(__XOP__) && (defined(__GNUC__) || defined(__clang__)) +#include +#endif + +#if !defined(__AVX512F__) +#if !defined(__AVX2__) +#if !defined(__XOP__) +#if defined(__SSSE3__) +#define r16 \ + (_mm_setr_epi8(2, 3, 4, 5, 6, 7, 0, 1, 10, 11, 12, 13, 14, 15, 8, 9)) +#define r24 \ + (_mm_setr_epi8(3, 4, 5, 6, 7, 0, 1, 2, 11, 12, 13, 14, 15, 8, 9, 10)) +#define _mm_roti_epi64(x, c) \ + (-(c) == 32) \ + ? _mm_shuffle_epi32((x), _MM_SHUFFLE(2, 3, 0, 1)) \ + : (-(c) == 24) \ + ? _mm_shuffle_epi8((x), r24) \ + : (-(c) == 16) \ + ? _mm_shuffle_epi8((x), r16) \ + : (-(c) == 63) \ + ? _mm_xor_si128(_mm_srli_epi64((x), -(c)), \ + _mm_add_epi64((x), (x))) \ + : _mm_xor_si128(_mm_srli_epi64((x), -(c)), \ + _mm_slli_epi64((x), 64 - (-(c)))) +#else /* defined(__SSE2__) */ +#define _mm_roti_epi64(r, c) \ + _mm_xor_si128(_mm_srli_epi64((r), -(c)), _mm_slli_epi64((r), 64 - (-(c)))) +#endif +#else +#endif + +static BLAKE2_INLINE __m128i fBlaMka(__m128i x, __m128i y) { + const __m128i z = _mm_mul_epu32(x, y); + return _mm_add_epi64(_mm_add_epi64(x, y), _mm_add_epi64(z, z)); +} + +#define G1(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + A0 = fBlaMka(A0, B0); \ + A1 = fBlaMka(A1, B1); \ + \ + D0 = _mm_xor_si128(D0, A0); \ + D1 = _mm_xor_si128(D1, A1); \ + \ + D0 = _mm_roti_epi64(D0, -32); \ + D1 = _mm_roti_epi64(D1, -32); \ + \ + C0 = fBlaMka(C0, D0); \ + C1 = fBlaMka(C1, D1); \ + \ + B0 = _mm_xor_si128(B0, C0); \ + B1 = _mm_xor_si128(B1, C1); \ + \ + B0 = _mm_roti_epi64(B0, -24); \ + B1 = _mm_roti_epi64(B1, -24); \ + } while ((void)0, 0) + +#define G2(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + A0 = fBlaMka(A0, B0); \ + A1 = fBlaMka(A1, B1); \ + \ + D0 = _mm_xor_si128(D0, A0); \ + D1 = _mm_xor_si128(D1, A1); \ + \ + D0 = _mm_roti_epi64(D0, -16); \ + D1 = _mm_roti_epi64(D1, -16); \ + \ + C0 = fBlaMka(C0, D0); \ + C1 = fBlaMka(C1, D1); \ + \ + B0 = _mm_xor_si128(B0, C0); \ + B1 = _mm_xor_si128(B1, C1); \ + \ + B0 = _mm_roti_epi64(B0, -63); \ + B1 = _mm_roti_epi64(B1, -63); \ + } while ((void)0, 0) + +#if defined(__SSSE3__) +#define DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + __m128i t0 = _mm_alignr_epi8(B1, B0, 8); \ + __m128i t1 = _mm_alignr_epi8(B0, B1, 8); \ + B0 = t0; \ + B1 = t1; \ + \ + t0 = C0; \ + C0 = C1; \ + C1 = t0; \ + \ + t0 = _mm_alignr_epi8(D1, D0, 8); \ + t1 = _mm_alignr_epi8(D0, D1, 8); \ + D0 = t1; \ + D1 = t0; \ + } while ((void)0, 0) + +#define UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + __m128i t0 = _mm_alignr_epi8(B0, B1, 8); \ + __m128i t1 = _mm_alignr_epi8(B1, B0, 8); \ + B0 = t0; \ + B1 = t1; \ + \ + t0 = C0; \ + C0 = C1; \ + C1 = t0; \ + \ + t0 = _mm_alignr_epi8(D0, D1, 8); \ + t1 = _mm_alignr_epi8(D1, D0, 8); \ + D0 = t1; \ + D1 = t0; \ + } while ((void)0, 0) +#else /* SSE2 */ +#define DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + __m128i t0 = D0; \ + __m128i t1 = B0; \ + D0 = C0; \ + C0 = C1; \ + C1 = D0; \ + D0 = _mm_unpackhi_epi64(D1, _mm_unpacklo_epi64(t0, t0)); \ + D1 = _mm_unpackhi_epi64(t0, _mm_unpacklo_epi64(D1, D1)); \ + B0 = _mm_unpackhi_epi64(B0, _mm_unpacklo_epi64(B1, B1)); \ + B1 = _mm_unpackhi_epi64(B1, _mm_unpacklo_epi64(t1, t1)); \ + } while ((void)0, 0) + +#define UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + __m128i t0, t1; \ + t0 = C0; \ + C0 = C1; \ + C1 = t0; \ + t0 = B0; \ + t1 = D0; \ + B0 = _mm_unpackhi_epi64(B1, _mm_unpacklo_epi64(B0, B0)); \ + B1 = _mm_unpackhi_epi64(t0, _mm_unpacklo_epi64(B1, B1)); \ + D0 = _mm_unpackhi_epi64(D0, _mm_unpacklo_epi64(D1, D1)); \ + D1 = _mm_unpackhi_epi64(D1, _mm_unpacklo_epi64(t1, t1)); \ + } while ((void)0, 0) +#endif + +#define BLAKE2_ROUND(A0, A1, B0, B1, C0, C1, D0, D1) \ + do { \ + G1(A0, B0, C0, D0, A1, B1, C1, D1); \ + G2(A0, B0, C0, D0, A1, B1, C1, D1); \ + \ + DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1); \ + \ + G1(A0, B0, C0, D0, A1, B1, C1, D1); \ + G2(A0, B0, C0, D0, A1, B1, C1, D1); \ + \ + UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1); \ + } while ((void)0, 0) +#else /* __AVX2__ */ + +#include + +#define rotr32(x) _mm256_shuffle_epi32(x, _MM_SHUFFLE(2, 3, 0, 1)) +#define rotr24(x) _mm256_shuffle_epi8(x, _mm256_setr_epi8(3, 4, 5, 6, 7, 0, 1, 2, 11, 12, 13, 14, 15, 8, 9, 10, 3, 4, 5, 6, 7, 0, 1, 2, 11, 12, 13, 14, 15, 8, 9, 10)) +#define rotr16(x) _mm256_shuffle_epi8(x, _mm256_setr_epi8(2, 3, 4, 5, 6, 7, 0, 1, 10, 11, 12, 13, 14, 15, 8, 9, 2, 3, 4, 5, 6, 7, 0, 1, 10, 11, 12, 13, 14, 15, 8, 9)) +#define rotr63(x) _mm256_xor_si256(_mm256_srli_epi64((x), 63), _mm256_add_epi64((x), (x))) + +#define G1_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + do { \ + __m256i ml = _mm256_mul_epu32(A0, B0); \ + ml = _mm256_add_epi64(ml, ml); \ + A0 = _mm256_add_epi64(A0, _mm256_add_epi64(B0, ml)); \ + D0 = _mm256_xor_si256(D0, A0); \ + D0 = rotr32(D0); \ + \ + ml = _mm256_mul_epu32(C0, D0); \ + ml = _mm256_add_epi64(ml, ml); \ + C0 = _mm256_add_epi64(C0, _mm256_add_epi64(D0, ml)); \ + \ + B0 = _mm256_xor_si256(B0, C0); \ + B0 = rotr24(B0); \ + \ + ml = _mm256_mul_epu32(A1, B1); \ + ml = _mm256_add_epi64(ml, ml); \ + A1 = _mm256_add_epi64(A1, _mm256_add_epi64(B1, ml)); \ + D1 = _mm256_xor_si256(D1, A1); \ + D1 = rotr32(D1); \ + \ + ml = _mm256_mul_epu32(C1, D1); \ + ml = _mm256_add_epi64(ml, ml); \ + C1 = _mm256_add_epi64(C1, _mm256_add_epi64(D1, ml)); \ + \ + B1 = _mm256_xor_si256(B1, C1); \ + B1 = rotr24(B1); \ + } while((void)0, 0); + +#define G2_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + do { \ + __m256i ml = _mm256_mul_epu32(A0, B0); \ + ml = _mm256_add_epi64(ml, ml); \ + A0 = _mm256_add_epi64(A0, _mm256_add_epi64(B0, ml)); \ + D0 = _mm256_xor_si256(D0, A0); \ + D0 = rotr16(D0); \ + \ + ml = _mm256_mul_epu32(C0, D0); \ + ml = _mm256_add_epi64(ml, ml); \ + C0 = _mm256_add_epi64(C0, _mm256_add_epi64(D0, ml)); \ + B0 = _mm256_xor_si256(B0, C0); \ + B0 = rotr63(B0); \ + \ + ml = _mm256_mul_epu32(A1, B1); \ + ml = _mm256_add_epi64(ml, ml); \ + A1 = _mm256_add_epi64(A1, _mm256_add_epi64(B1, ml)); \ + D1 = _mm256_xor_si256(D1, A1); \ + D1 = rotr16(D1); \ + \ + ml = _mm256_mul_epu32(C1, D1); \ + ml = _mm256_add_epi64(ml, ml); \ + C1 = _mm256_add_epi64(C1, _mm256_add_epi64(D1, ml)); \ + B1 = _mm256_xor_si256(B1, C1); \ + B1 = rotr63(B1); \ + } while((void)0, 0); + +#define DIAGONALIZE_1(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + B0 = _mm256_permute4x64_epi64(B0, _MM_SHUFFLE(0, 3, 2, 1)); \ + C0 = _mm256_permute4x64_epi64(C0, _MM_SHUFFLE(1, 0, 3, 2)); \ + D0 = _mm256_permute4x64_epi64(D0, _MM_SHUFFLE(2, 1, 0, 3)); \ + \ + B1 = _mm256_permute4x64_epi64(B1, _MM_SHUFFLE(0, 3, 2, 1)); \ + C1 = _mm256_permute4x64_epi64(C1, _MM_SHUFFLE(1, 0, 3, 2)); \ + D1 = _mm256_permute4x64_epi64(D1, _MM_SHUFFLE(2, 1, 0, 3)); \ + } while((void)0, 0); + +#define DIAGONALIZE_2(A0, A1, B0, B1, C0, C1, D0, D1) \ + do { \ + __m256i tmp1 = _mm256_blend_epi32(B0, B1, 0xCC); \ + __m256i tmp2 = _mm256_blend_epi32(B0, B1, 0x33); \ + B1 = _mm256_permute4x64_epi64(tmp1, _MM_SHUFFLE(2,3,0,1)); \ + B0 = _mm256_permute4x64_epi64(tmp2, _MM_SHUFFLE(2,3,0,1)); \ + \ + tmp1 = C0; \ + C0 = C1; \ + C1 = tmp1; \ + \ + tmp1 = _mm256_blend_epi32(D0, D1, 0xCC); \ + tmp2 = _mm256_blend_epi32(D0, D1, 0x33); \ + D0 = _mm256_permute4x64_epi64(tmp1, _MM_SHUFFLE(2,3,0,1)); \ + D1 = _mm256_permute4x64_epi64(tmp2, _MM_SHUFFLE(2,3,0,1)); \ + } while(0); + +#define UNDIAGONALIZE_1(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + B0 = _mm256_permute4x64_epi64(B0, _MM_SHUFFLE(2, 1, 0, 3)); \ + C0 = _mm256_permute4x64_epi64(C0, _MM_SHUFFLE(1, 0, 3, 2)); \ + D0 = _mm256_permute4x64_epi64(D0, _MM_SHUFFLE(0, 3, 2, 1)); \ + \ + B1 = _mm256_permute4x64_epi64(B1, _MM_SHUFFLE(2, 1, 0, 3)); \ + C1 = _mm256_permute4x64_epi64(C1, _MM_SHUFFLE(1, 0, 3, 2)); \ + D1 = _mm256_permute4x64_epi64(D1, _MM_SHUFFLE(0, 3, 2, 1)); \ + } while((void)0, 0); + +#define UNDIAGONALIZE_2(A0, A1, B0, B1, C0, C1, D0, D1) \ + do { \ + __m256i tmp1 = _mm256_blend_epi32(B0, B1, 0xCC); \ + __m256i tmp2 = _mm256_blend_epi32(B0, B1, 0x33); \ + B0 = _mm256_permute4x64_epi64(tmp1, _MM_SHUFFLE(2,3,0,1)); \ + B1 = _mm256_permute4x64_epi64(tmp2, _MM_SHUFFLE(2,3,0,1)); \ + \ + tmp1 = C0; \ + C0 = C1; \ + C1 = tmp1; \ + \ + tmp1 = _mm256_blend_epi32(D0, D1, 0x33); \ + tmp2 = _mm256_blend_epi32(D0, D1, 0xCC); \ + D0 = _mm256_permute4x64_epi64(tmp1, _MM_SHUFFLE(2,3,0,1)); \ + D1 = _mm256_permute4x64_epi64(tmp2, _MM_SHUFFLE(2,3,0,1)); \ + } while((void)0, 0); + +#define BLAKE2_ROUND_1(A0, A1, B0, B1, C0, C1, D0, D1) \ + do{ \ + G1_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + G2_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + \ + DIAGONALIZE_1(A0, B0, C0, D0, A1, B1, C1, D1) \ + \ + G1_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + G2_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + \ + UNDIAGONALIZE_1(A0, B0, C0, D0, A1, B1, C1, D1) \ + } while((void)0, 0); + +#define BLAKE2_ROUND_2(A0, A1, B0, B1, C0, C1, D0, D1) \ + do{ \ + G1_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + G2_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + \ + DIAGONALIZE_2(A0, A1, B0, B1, C0, C1, D0, D1) \ + \ + G1_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + G2_AVX2(A0, A1, B0, B1, C0, C1, D0, D1) \ + \ + UNDIAGONALIZE_2(A0, A1, B0, B1, C0, C1, D0, D1) \ + } while((void)0, 0); + +#endif /* __AVX2__ */ + +#else /* __AVX512F__ */ + +#include + +#define ror64(x, n) _mm512_ror_epi64((x), (n)) + +static __m512i muladd(__m512i x, __m512i y) +{ + __m512i z = _mm512_mul_epu32(x, y); + return _mm512_add_epi64(_mm512_add_epi64(x, y), _mm512_add_epi64(z, z)); +} + +#define G1(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + A0 = muladd(A0, B0); \ + A1 = muladd(A1, B1); \ +\ + D0 = _mm512_xor_si512(D0, A0); \ + D1 = _mm512_xor_si512(D1, A1); \ +\ + D0 = ror64(D0, 32); \ + D1 = ror64(D1, 32); \ +\ + C0 = muladd(C0, D0); \ + C1 = muladd(C1, D1); \ +\ + B0 = _mm512_xor_si512(B0, C0); \ + B1 = _mm512_xor_si512(B1, C1); \ +\ + B0 = ror64(B0, 24); \ + B1 = ror64(B1, 24); \ + } while ((void)0, 0) + +#define G2(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + A0 = muladd(A0, B0); \ + A1 = muladd(A1, B1); \ +\ + D0 = _mm512_xor_si512(D0, A0); \ + D1 = _mm512_xor_si512(D1, A1); \ +\ + D0 = ror64(D0, 16); \ + D1 = ror64(D1, 16); \ +\ + C0 = muladd(C0, D0); \ + C1 = muladd(C1, D1); \ +\ + B0 = _mm512_xor_si512(B0, C0); \ + B1 = _mm512_xor_si512(B1, C1); \ +\ + B0 = ror64(B0, 63); \ + B1 = ror64(B1, 63); \ + } while ((void)0, 0) + +#define DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + B0 = _mm512_permutex_epi64(B0, _MM_SHUFFLE(0, 3, 2, 1)); \ + B1 = _mm512_permutex_epi64(B1, _MM_SHUFFLE(0, 3, 2, 1)); \ +\ + C0 = _mm512_permutex_epi64(C0, _MM_SHUFFLE(1, 0, 3, 2)); \ + C1 = _mm512_permutex_epi64(C1, _MM_SHUFFLE(1, 0, 3, 2)); \ +\ + D0 = _mm512_permutex_epi64(D0, _MM_SHUFFLE(2, 1, 0, 3)); \ + D1 = _mm512_permutex_epi64(D1, _MM_SHUFFLE(2, 1, 0, 3)); \ + } while ((void)0, 0) + +#define UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + B0 = _mm512_permutex_epi64(B0, _MM_SHUFFLE(2, 1, 0, 3)); \ + B1 = _mm512_permutex_epi64(B1, _MM_SHUFFLE(2, 1, 0, 3)); \ +\ + C0 = _mm512_permutex_epi64(C0, _MM_SHUFFLE(1, 0, 3, 2)); \ + C1 = _mm512_permutex_epi64(C1, _MM_SHUFFLE(1, 0, 3, 2)); \ +\ + D0 = _mm512_permutex_epi64(D0, _MM_SHUFFLE(0, 3, 2, 1)); \ + D1 = _mm512_permutex_epi64(D1, _MM_SHUFFLE(0, 3, 2, 1)); \ + } while ((void)0, 0) + +#define BLAKE2_ROUND(A0, B0, C0, D0, A1, B1, C1, D1) \ + do { \ + G1(A0, B0, C0, D0, A1, B1, C1, D1); \ + G2(A0, B0, C0, D0, A1, B1, C1, D1); \ +\ + DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1); \ +\ + G1(A0, B0, C0, D0, A1, B1, C1, D1); \ + G2(A0, B0, C0, D0, A1, B1, C1, D1); \ +\ + UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1); \ + } while ((void)0, 0) + +#define SWAP_HALVES(A0, A1) \ + do { \ + __m512i t0, t1; \ + t0 = _mm512_shuffle_i64x2(A0, A1, _MM_SHUFFLE(1, 0, 1, 0)); \ + t1 = _mm512_shuffle_i64x2(A0, A1, _MM_SHUFFLE(3, 2, 3, 2)); \ + A0 = t0; \ + A1 = t1; \ + } while((void)0, 0) + +#define SWAP_QUARTERS(A0, A1) \ + do { \ + SWAP_HALVES(A0, A1); \ + A0 = _mm512_permutexvar_epi64(_mm512_setr_epi64(0, 1, 4, 5, 2, 3, 6, 7), A0); \ + A1 = _mm512_permutexvar_epi64(_mm512_setr_epi64(0, 1, 4, 5, 2, 3, 6, 7), A1); \ + } while((void)0, 0) + +#define UNSWAP_QUARTERS(A0, A1) \ + do { \ + A0 = _mm512_permutexvar_epi64(_mm512_setr_epi64(0, 1, 4, 5, 2, 3, 6, 7), A0); \ + A1 = _mm512_permutexvar_epi64(_mm512_setr_epi64(0, 1, 4, 5, 2, 3, 6, 7), A1); \ + SWAP_HALVES(A0, A1); \ + } while((void)0, 0) + +#define BLAKE2_ROUND_1(A0, C0, B0, D0, A1, C1, B1, D1) \ + do { \ + SWAP_HALVES(A0, B0); \ + SWAP_HALVES(C0, D0); \ + SWAP_HALVES(A1, B1); \ + SWAP_HALVES(C1, D1); \ + BLAKE2_ROUND(A0, B0, C0, D0, A1, B1, C1, D1); \ + SWAP_HALVES(A0, B0); \ + SWAP_HALVES(C0, D0); \ + SWAP_HALVES(A1, B1); \ + SWAP_HALVES(C1, D1); \ + } while ((void)0, 0) + +#define BLAKE2_ROUND_2(A0, A1, B0, B1, C0, C1, D0, D1) \ + do { \ + SWAP_QUARTERS(A0, A1); \ + SWAP_QUARTERS(B0, B1); \ + SWAP_QUARTERS(C0, C1); \ + SWAP_QUARTERS(D0, D1); \ + BLAKE2_ROUND(A0, B0, C0, D0, A1, B1, C1, D1); \ + UNSWAP_QUARTERS(A0, A1); \ + UNSWAP_QUARTERS(B0, B1); \ + UNSWAP_QUARTERS(C0, C1); \ + UNSWAP_QUARTERS(D0, D1); \ + } while ((void)0, 0) + +#endif /* __AVX512F__ */ +#endif /* BLAKE_ROUND_MKA_OPT_H */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blamka-round-ref.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blamka-round-ref.h new file mode 100644 index 0000000000..16cfc1c74a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/blake2/blamka-round-ref.h @@ -0,0 +1,56 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef BLAKE_ROUND_MKA_H +#define BLAKE_ROUND_MKA_H + +#include "blake2.h" +#include "blake2-impl.h" + +/* designed by the Lyra PHC team */ +static BLAKE2_INLINE uint64_t fBlaMka(uint64_t x, uint64_t y) { + const uint64_t m = UINT64_C(0xFFFFFFFF); + const uint64_t xy = (x & m) * (y & m); + return x + y + 2 * xy; +} + +#define G(a, b, c, d) \ + do { \ + a = fBlaMka(a, b); \ + d = rotr64(d ^ a, 32); \ + c = fBlaMka(c, d); \ + b = rotr64(b ^ c, 24); \ + a = fBlaMka(a, b); \ + d = rotr64(d ^ a, 16); \ + c = fBlaMka(c, d); \ + b = rotr64(b ^ c, 63); \ + } while ((void)0, 0) + +#define BLAKE2_ROUND_NOMSG(v0, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, \ + v12, v13, v14, v15) \ + do { \ + G(v0, v4, v8, v12); \ + G(v1, v5, v9, v13); \ + G(v2, v6, v10, v14); \ + G(v3, v7, v11, v15); \ + G(v0, v5, v10, v15); \ + G(v1, v6, v11, v12); \ + G(v2, v7, v8, v13); \ + G(v3, v4, v9, v14); \ + } while ((void)0, 0) + +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/core.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/core.c new file mode 100644 index 0000000000..e697882eb9 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/core.c @@ -0,0 +1,648 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +/*For memory wiping*/ +#ifdef _WIN32 +#include +#include /* For SecureZeroMemory */ +#endif +#if defined __STDC_LIB_EXT1__ +#define __STDC_WANT_LIB_EXT1__ 1 +#endif +#define VC_GE_2005(version) (version >= 1400) + +/* for explicit_bzero() on glibc */ +#define _DEFAULT_SOURCE + +#include +#include +#include + +#include "core.h" +#include "thread.h" +#include "blake2/blake2.h" +#include "blake2/blake2-impl.h" + +#ifdef GENKAT +#include "genkat.h" +#endif + +#if defined(__clang__) +#if __has_attribute(optnone) +#define NOT_OPTIMIZED __attribute__((optnone)) +#endif +#elif defined(__GNUC__) +#define GCC_VERSION \ + (__GNUC__ * 10000 + __GNUC_MINOR__ * 100 + __GNUC_PATCHLEVEL__) +#if GCC_VERSION >= 40400 +#define NOT_OPTIMIZED __attribute__((optimize("O0"))) +#endif +#endif +#ifndef NOT_OPTIMIZED +#define NOT_OPTIMIZED +#endif + +/***************Instance and Position constructors**********/ +void init_block_value(block *b, uint8_t in) { memset(b->v, in, sizeof(b->v)); } + +void copy_block(block *dst, const block *src) { + memcpy(dst->v, src->v, sizeof(uint64_t) * ARGON2_QWORDS_IN_BLOCK); +} + +void xor_block(block *dst, const block *src) { + int i; + for (i = 0; i < ARGON2_QWORDS_IN_BLOCK; ++i) { + dst->v[i] ^= src->v[i]; + } +} + +static void load_block(block *dst, const void *input) { + unsigned i; + for (i = 0; i < ARGON2_QWORDS_IN_BLOCK; ++i) { + dst->v[i] = load64((const uint8_t *)input + i * sizeof(dst->v[i])); + } +} + +static void store_block(void *output, const block *src) { + unsigned i; + for (i = 0; i < ARGON2_QWORDS_IN_BLOCK; ++i) { + store64((uint8_t *)output + i * sizeof(src->v[i]), src->v[i]); + } +} + +/***************Memory functions*****************/ + +int allocate_memory(const argon2_context *context, uint8_t **memory, + size_t num, size_t size) { + size_t memory_size = num*size; + if (memory == NULL) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + /* 1. Check for multiplication overflow */ + if (size != 0 && memory_size / size != num) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + /* 2. Try to allocate with appropriate allocator */ + if (context->allocate_cbk) { + (context->allocate_cbk)(memory, memory_size); + } else { + *memory = malloc(memory_size); + } + + if (*memory == NULL) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + return ARGON2_OK; +} + +void free_memory(const argon2_context *context, uint8_t *memory, + size_t num, size_t size) { + size_t memory_size = num*size; + clear_internal_memory(memory, memory_size); + if (context->free_cbk) { + (context->free_cbk)(memory, memory_size); + } else { + free(memory); + } +} + +#if defined(__OpenBSD__) +#define HAVE_EXPLICIT_BZERO 1 +#elif defined(__GLIBC__) && defined(__GLIBC_PREREQ) +#if __GLIBC_PREREQ(2,25) +#define HAVE_EXPLICIT_BZERO 1 +#endif +#endif + +void NOT_OPTIMIZED secure_wipe_memory(void *v, size_t n) { +#if defined(_MSC_VER) && VC_GE_2005(_MSC_VER) || defined(__MINGW32__) + SecureZeroMemory(v, n); +#elif defined memset_s + memset_s(v, n, 0, n); +#elif defined(HAVE_EXPLICIT_BZERO) + explicit_bzero(v, n); +#else + static void *(*const volatile memset_sec)(void *, int, size_t) = &memset; + memset_sec(v, 0, n); +#endif +} + +/* Memory clear flag defaults to true. */ +int FLAG_clear_internal_memory = 1; +void clear_internal_memory(void *v, size_t n) { + if (FLAG_clear_internal_memory && v) { + secure_wipe_memory(v, n); + } +} + +void finalize(const argon2_context *context, argon2_instance_t *instance) { + if (context != NULL && instance != NULL) { + block blockhash; + uint32_t l; + + copy_block(&blockhash, instance->memory + instance->lane_length - 1); + + /* XOR the last blocks */ + for (l = 1; l < instance->lanes; ++l) { + uint32_t last_block_in_lane = + l * instance->lane_length + (instance->lane_length - 1); + xor_block(&blockhash, instance->memory + last_block_in_lane); + } + + /* Hash the result */ + { + uint8_t blockhash_bytes[ARGON2_BLOCK_SIZE]; + store_block(blockhash_bytes, &blockhash); + blake2b_long(context->out, context->outlen, blockhash_bytes, + ARGON2_BLOCK_SIZE); + /* clear blockhash and blockhash_bytes */ + clear_internal_memory(blockhash.v, ARGON2_BLOCK_SIZE); + clear_internal_memory(blockhash_bytes, ARGON2_BLOCK_SIZE); + } + +#ifdef GENKAT + print_tag(context->out, context->outlen); +#endif + + free_memory(context, (uint8_t *)instance->memory, + instance->memory_blocks, sizeof(block)); + } +} + +uint32_t index_alpha(const argon2_instance_t *instance, + const argon2_position_t *position, uint32_t pseudo_rand, + int same_lane) { + /* + * Pass 0: + * This lane : all already finished segments plus already constructed + * blocks in this segment + * Other lanes : all already finished segments + * Pass 1+: + * This lane : (SYNC_POINTS - 1) last segments plus already constructed + * blocks in this segment + * Other lanes : (SYNC_POINTS - 1) last segments + */ + uint32_t reference_area_size; + uint64_t relative_position; + uint32_t start_position, absolute_position; + + if (0 == position->pass) { + /* First pass */ + if (0 == position->slice) { + /* First slice */ + reference_area_size = + position->index - 1; /* all but the previous */ + } else { + if (same_lane) { + /* The same lane => add current segment */ + reference_area_size = + position->slice * instance->segment_length + + position->index - 1; + } else { + reference_area_size = + position->slice * instance->segment_length + + ((position->index == 0) ? (-1) : 0); + } + } + } else { + /* Second pass */ + if (same_lane) { + reference_area_size = instance->lane_length - + instance->segment_length + position->index - + 1; + } else { + reference_area_size = instance->lane_length - + instance->segment_length + + ((position->index == 0) ? (-1) : 0); + } + } + + /* 1.2.4. Mapping pseudo_rand to 0.. and produce + * relative position */ + relative_position = pseudo_rand; + relative_position = relative_position * relative_position >> 32; + relative_position = reference_area_size - 1 - + (reference_area_size * relative_position >> 32); + + /* 1.2.5 Computing starting position */ + start_position = 0; + + if (0 != position->pass) { + start_position = (position->slice == ARGON2_SYNC_POINTS - 1) + ? 0 + : (position->slice + 1) * instance->segment_length; + } + + /* 1.2.6. Computing absolute position */ + absolute_position = (start_position + relative_position) % + instance->lane_length; /* absolute position */ + return absolute_position; +} + +/* Single-threaded version for p=1 case */ +static int fill_memory_blocks_st(argon2_instance_t *instance) { + uint32_t r, s, l; + + for (r = 0; r < instance->passes; ++r) { + for (s = 0; s < ARGON2_SYNC_POINTS; ++s) { + for (l = 0; l < instance->lanes; ++l) { + argon2_position_t position = {r, l, (uint8_t)s, 0}; + fill_segment(instance, position); + } + } +#ifdef GENKAT + internal_kat(instance, r); /* Print all memory blocks */ +#endif + } + return ARGON2_OK; +} + +#if !defined(ARGON2_NO_THREADS) + +#ifdef _WIN32 +static unsigned __stdcall fill_segment_thr(void *thread_data) +#else +static void *fill_segment_thr(void *thread_data) +#endif +{ + argon2_thread_data *my_data = thread_data; + fill_segment(my_data->instance_ptr, my_data->pos); + argon2_thread_exit(); + return 0; +} + +/* Multi-threaded version for p > 1 case */ +static int fill_memory_blocks_mt(argon2_instance_t *instance) { + uint32_t r, s; + argon2_thread_handle_t *thread = NULL; + argon2_thread_data *thr_data = NULL; + int rc = ARGON2_OK; + + /* 1. Allocating space for threads */ + thread = calloc(instance->lanes, sizeof(argon2_thread_handle_t)); + if (thread == NULL) { + rc = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + thr_data = calloc(instance->lanes, sizeof(argon2_thread_data)); + if (thr_data == NULL) { + rc = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + for (r = 0; r < instance->passes; ++r) { + for (s = 0; s < ARGON2_SYNC_POINTS; ++s) { + uint32_t l, ll; + + /* 2. Calling threads */ + for (l = 0; l < instance->lanes; ++l) { + argon2_position_t position; + + /* 2.1 Join a thread if limit is exceeded */ + if (l >= instance->threads) { + if (argon2_thread_join(thread[l - instance->threads])) { + rc = ARGON2_THREAD_FAIL; + goto fail; + } + } + + /* 2.2 Create thread */ + position.pass = r; + position.lane = l; + position.slice = (uint8_t)s; + position.index = 0; + thr_data[l].instance_ptr = + instance; /* preparing the thread input */ + memcpy(&(thr_data[l].pos), &position, + sizeof(argon2_position_t)); + if (argon2_thread_create(&thread[l], &fill_segment_thr, + (void *)&thr_data[l])) { + /* Wait for already running threads */ + for (ll = 0; ll < l; ++ll) + argon2_thread_join(thread[ll]); + rc = ARGON2_THREAD_FAIL; + goto fail; + } + + /* fill_segment(instance, position); */ + /*Non-thread equivalent of the lines above */ + } + + /* 3. Joining remaining threads */ + for (l = instance->lanes - instance->threads; l < instance->lanes; + ++l) { + if (argon2_thread_join(thread[l])) { + rc = ARGON2_THREAD_FAIL; + goto fail; + } + } + } + +#ifdef GENKAT + internal_kat(instance, r); /* Print all memory blocks */ +#endif + } + +fail: + if (thread != NULL) { + free(thread); + } + if (thr_data != NULL) { + free(thr_data); + } + return rc; +} + +#endif /* ARGON2_NO_THREADS */ + +int fill_memory_blocks(argon2_instance_t *instance) { + if (instance == NULL || instance->lanes == 0) { + return ARGON2_INCORRECT_PARAMETER; + } +#if defined(ARGON2_NO_THREADS) + return fill_memory_blocks_st(instance); +#else + return instance->threads == 1 ? + fill_memory_blocks_st(instance) : fill_memory_blocks_mt(instance); +#endif +} + +int validate_inputs(const argon2_context *context) { + if (NULL == context) { + return ARGON2_INCORRECT_PARAMETER; + } + + if (NULL == context->out) { + return ARGON2_OUTPUT_PTR_NULL; + } + + /* Validate output length */ + if (ARGON2_MIN_OUTLEN > context->outlen) { + return ARGON2_OUTPUT_TOO_SHORT; + } + + if (ARGON2_MAX_OUTLEN < context->outlen) { + return ARGON2_OUTPUT_TOO_LONG; + } + + /* Validate password (required param) */ + if (NULL == context->pwd) { + if (0 != context->pwdlen) { + return ARGON2_PWD_PTR_MISMATCH; + } + } + + if (ARGON2_MIN_PWD_LENGTH > context->pwdlen) { + return ARGON2_PWD_TOO_SHORT; + } + + if (ARGON2_MAX_PWD_LENGTH < context->pwdlen) { + return ARGON2_PWD_TOO_LONG; + } + + /* Validate salt (required param) */ + if (NULL == context->salt) { + if (0 != context->saltlen) { + return ARGON2_SALT_PTR_MISMATCH; + } + } + + if (ARGON2_MIN_SALT_LENGTH > context->saltlen) { + return ARGON2_SALT_TOO_SHORT; + } + + if (ARGON2_MAX_SALT_LENGTH < context->saltlen) { + return ARGON2_SALT_TOO_LONG; + } + + /* Validate secret (optional param) */ + if (NULL == context->secret) { + if (0 != context->secretlen) { + return ARGON2_SECRET_PTR_MISMATCH; + } + } else { + if (ARGON2_MIN_SECRET > context->secretlen) { + return ARGON2_SECRET_TOO_SHORT; + } + if (ARGON2_MAX_SECRET < context->secretlen) { + return ARGON2_SECRET_TOO_LONG; + } + } + + /* Validate associated data (optional param) */ + if (NULL == context->ad) { + if (0 != context->adlen) { + return ARGON2_AD_PTR_MISMATCH; + } + } else { + if (ARGON2_MIN_AD_LENGTH > context->adlen) { + return ARGON2_AD_TOO_SHORT; + } + if (ARGON2_MAX_AD_LENGTH < context->adlen) { + return ARGON2_AD_TOO_LONG; + } + } + + /* Validate memory cost */ + if (ARGON2_MIN_MEMORY > context->m_cost) { + return ARGON2_MEMORY_TOO_LITTLE; + } + + if (ARGON2_MAX_MEMORY < context->m_cost) { + return ARGON2_MEMORY_TOO_MUCH; + } + + if (context->m_cost < 8 * context->lanes) { + return ARGON2_MEMORY_TOO_LITTLE; + } + + /* Validate time cost */ + if (ARGON2_MIN_TIME > context->t_cost) { + return ARGON2_TIME_TOO_SMALL; + } + + if (ARGON2_MAX_TIME < context->t_cost) { + return ARGON2_TIME_TOO_LARGE; + } + + /* Validate lanes */ + if (ARGON2_MIN_LANES > context->lanes) { + return ARGON2_LANES_TOO_FEW; + } + + if (ARGON2_MAX_LANES < context->lanes) { + return ARGON2_LANES_TOO_MANY; + } + + /* Validate threads */ + if (ARGON2_MIN_THREADS > context->threads) { + return ARGON2_THREADS_TOO_FEW; + } + + if (ARGON2_MAX_THREADS < context->threads) { + return ARGON2_THREADS_TOO_MANY; + } + + if (NULL != context->allocate_cbk && NULL == context->free_cbk) { + return ARGON2_FREE_MEMORY_CBK_NULL; + } + + if (NULL == context->allocate_cbk && NULL != context->free_cbk) { + return ARGON2_ALLOCATE_MEMORY_CBK_NULL; + } + + return ARGON2_OK; +} + +void fill_first_blocks(uint8_t *blockhash, const argon2_instance_t *instance) { + uint32_t l; + /* Make the first and second block in each lane as G(H0||0||i) or + G(H0||1||i) */ + uint8_t blockhash_bytes[ARGON2_BLOCK_SIZE]; + for (l = 0; l < instance->lanes; ++l) { + + store32(blockhash + ARGON2_PREHASH_DIGEST_LENGTH, 0); + store32(blockhash + ARGON2_PREHASH_DIGEST_LENGTH + 4, l); + blake2b_long(blockhash_bytes, ARGON2_BLOCK_SIZE, blockhash, + ARGON2_PREHASH_SEED_LENGTH); + load_block(&instance->memory[l * instance->lane_length + 0], + blockhash_bytes); + + store32(blockhash + ARGON2_PREHASH_DIGEST_LENGTH, 1); + blake2b_long(blockhash_bytes, ARGON2_BLOCK_SIZE, blockhash, + ARGON2_PREHASH_SEED_LENGTH); + load_block(&instance->memory[l * instance->lane_length + 1], + blockhash_bytes); + } + clear_internal_memory(blockhash_bytes, ARGON2_BLOCK_SIZE); +} + +void initial_hash(uint8_t *blockhash, argon2_context *context, + argon2_type type) { + blake2b_state BlakeHash; + uint8_t value[sizeof(uint32_t)]; + + if (NULL == context || NULL == blockhash) { + return; + } + + blake2b_init(&BlakeHash, ARGON2_PREHASH_DIGEST_LENGTH); + + store32(&value, context->lanes); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->outlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->m_cost); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->t_cost); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->version); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, (uint32_t)type); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->pwdlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->pwd != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->pwd, + context->pwdlen); + + if (context->flags & ARGON2_FLAG_CLEAR_PASSWORD) { + secure_wipe_memory(context->pwd, context->pwdlen); + context->pwdlen = 0; + } + } + + store32(&value, context->saltlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->salt != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->salt, + context->saltlen); + } + + store32(&value, context->secretlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->secret != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->secret, + context->secretlen); + + if (context->flags & ARGON2_FLAG_CLEAR_SECRET) { + secure_wipe_memory(context->secret, context->secretlen); + context->secretlen = 0; + } + } + + store32(&value, context->adlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->ad != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->ad, + context->adlen); + } + + blake2b_final(&BlakeHash, blockhash, ARGON2_PREHASH_DIGEST_LENGTH); +} + +int initialize(argon2_instance_t *instance, argon2_context *context) { + uint8_t blockhash[ARGON2_PREHASH_SEED_LENGTH]; + int result = ARGON2_OK; + + if (instance == NULL || context == NULL) + return ARGON2_INCORRECT_PARAMETER; + instance->context_ptr = context; + + /* 1. Memory allocation */ + result = allocate_memory(context, (uint8_t **)&(instance->memory), + instance->memory_blocks, sizeof(block)); + if (result != ARGON2_OK) { + return result; + } + + /* 2. Initial hashing */ + /* H_0 + 8 extra bytes to produce the first blocks */ + /* uint8_t blockhash[ARGON2_PREHASH_SEED_LENGTH]; */ + /* Hashing all inputs */ + initial_hash(blockhash, context, instance->type); + /* Zeroing 8 extra bytes */ + clear_internal_memory(blockhash + ARGON2_PREHASH_DIGEST_LENGTH, + ARGON2_PREHASH_SEED_LENGTH - + ARGON2_PREHASH_DIGEST_LENGTH); + +#ifdef GENKAT + initial_kat(blockhash, context, instance->type); +#endif + + /* 3. Creating first blocks, we always have at least two blocks in a slice + */ + fill_first_blocks(blockhash, instance); + /* Clearing the hash */ + clear_internal_memory(blockhash, ARGON2_PREHASH_SEED_LENGTH); + + return ARGON2_OK; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/core.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/core.h new file mode 100644 index 0000000000..59e25646cb --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/core.h @@ -0,0 +1,228 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ARGON2_CORE_H +#define ARGON2_CORE_H + +#include "argon2.h" + +#define CONST_CAST(x) (x)(uintptr_t) + +/**********************Argon2 internal constants*******************************/ + +enum argon2_core_constants { + /* Memory block size in bytes */ + ARGON2_BLOCK_SIZE = 1024, + ARGON2_QWORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 8, + ARGON2_OWORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 16, + ARGON2_HWORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 32, + ARGON2_512BIT_WORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 64, + + /* Number of pseudo-random values generated by one call to Blake in Argon2i + to + generate reference block positions */ + ARGON2_ADDRESSES_IN_BLOCK = 128, + + /* Pre-hashing digest length and its extension*/ + ARGON2_PREHASH_DIGEST_LENGTH = 64, + ARGON2_PREHASH_SEED_LENGTH = 72 +}; + +/*************************Argon2 internal data types***********************/ + +/* + * Structure for the (1KB) memory block implemented as 128 64-bit words. + * Memory blocks can be copied, XORed. Internal words can be accessed by [] (no + * bounds checking). + */ +typedef struct block_ { uint64_t v[ARGON2_QWORDS_IN_BLOCK]; } block; + +/*****************Functions that work with the block******************/ + +/* Initialize each byte of the block with @in */ +void init_block_value(block *b, uint8_t in); + +/* Copy block @src to block @dst */ +void copy_block(block *dst, const block *src); + +/* XOR @src onto @dst bytewise */ +void xor_block(block *dst, const block *src); + +/* + * Argon2 instance: memory pointer, number of passes, amount of memory, type, + * and derived values. + * Used to evaluate the number and location of blocks to construct in each + * thread + */ +typedef struct Argon2_instance_t { + block *memory; /* Memory pointer */ + uint32_t version; + uint32_t passes; /* Number of passes */ + uint32_t memory_blocks; /* Number of blocks in memory */ + uint32_t segment_length; + uint32_t lane_length; + uint32_t lanes; + uint32_t threads; + argon2_type type; + int print_internals; /* whether to print the memory blocks */ + argon2_context *context_ptr; /* points back to original context */ +} argon2_instance_t; + +/* + * Argon2 position: where we construct the block right now. Used to distribute + * work between threads. + */ +typedef struct Argon2_position_t { + uint32_t pass; + uint32_t lane; + uint8_t slice; + uint32_t index; +} argon2_position_t; + +/*Struct that holds the inputs for thread handling FillSegment*/ +typedef struct Argon2_thread_data { + argon2_instance_t *instance_ptr; + argon2_position_t pos; +} argon2_thread_data; + +/*************************Argon2 core functions********************************/ + +/* Allocates memory to the given pointer, uses the appropriate allocator as + * specified in the context. Total allocated memory is num*size. + * @param context argon2_context which specifies the allocator + * @param memory pointer to the pointer to the memory + * @param size the size in bytes for each element to be allocated + * @param num the number of elements to be allocated + * @return ARGON2_OK if @memory is a valid pointer and memory is allocated + */ +int allocate_memory(const argon2_context *context, uint8_t **memory, + size_t num, size_t size); + +/* + * Frees memory at the given pointer, uses the appropriate deallocator as + * specified in the context. Also cleans the memory using clear_internal_memory. + * @param context argon2_context which specifies the deallocator + * @param memory pointer to buffer to be freed + * @param size the size in bytes for each element to be deallocated + * @param num the number of elements to be deallocated + */ +void free_memory(const argon2_context *context, uint8_t *memory, + size_t num, size_t size); + +/* Function that securely cleans the memory. This ignores any flags set + * regarding clearing memory. Usually one just calls clear_internal_memory. + * @param mem Pointer to the memory + * @param s Memory size in bytes + */ +void secure_wipe_memory(void *v, size_t n); + +/* Function that securely clears the memory if FLAG_clear_internal_memory is + * set. If the flag isn't set, this function does nothing. + * @param mem Pointer to the memory + * @param s Memory size in bytes + */ +void clear_internal_memory(void *v, size_t n); + +/* + * Computes absolute position of reference block in the lane following a skewed + * distribution and using a pseudo-random value as input + * @param instance Pointer to the current instance + * @param position Pointer to the current position + * @param pseudo_rand 32-bit pseudo-random value used to determine the position + * @param same_lane Indicates if the block will be taken from the current lane. + * If so we can reference the current segment + * @pre All pointers must be valid + */ +uint32_t index_alpha(const argon2_instance_t *instance, + const argon2_position_t *position, uint32_t pseudo_rand, + int same_lane); + +/* + * Function that validates all inputs against predefined restrictions and return + * an error code + * @param context Pointer to current Argon2 context + * @return ARGON2_OK if everything is all right, otherwise one of error codes + * (all defined in + */ +int validate_inputs(const argon2_context *context); + +/* + * Hashes all the inputs into @a blockhash[PREHASH_DIGEST_LENGTH], clears + * password and secret if needed + * @param context Pointer to the Argon2 internal structure containing memory + * pointer, and parameters for time and space requirements. + * @param blockhash Buffer for pre-hashing digest + * @param type Argon2 type + * @pre @a blockhash must have at least @a PREHASH_DIGEST_LENGTH bytes + * allocated + */ +void initial_hash(uint8_t *blockhash, argon2_context *context, + argon2_type type); + +/* + * Function creates first 2 blocks per lane + * @param instance Pointer to the current instance + * @param blockhash Pointer to the pre-hashing digest + * @pre blockhash must point to @a PREHASH_SEED_LENGTH allocated values + */ +void fill_first_blocks(uint8_t *blockhash, const argon2_instance_t *instance); + +/* + * Function allocates memory, hashes the inputs with Blake, and creates first + * two blocks. Returns the pointer to the main memory with 2 blocks per lane + * initialized + * @param context Pointer to the Argon2 internal structure containing memory + * pointer, and parameters for time and space requirements. + * @param instance Current Argon2 instance + * @return Zero if successful, -1 if memory failed to allocate. @context->state + * will be modified if successful. + */ +int initialize(argon2_instance_t *instance, argon2_context *context); + +/* + * XORing the last block of each lane, hashing it, making the tag. Deallocates + * the memory. + * @param context Pointer to current Argon2 context (use only the out parameters + * from it) + * @param instance Pointer to current instance of Argon2 + * @pre instance->state must point to necessary amount of memory + * @pre context->out must point to outlen bytes of memory + * @pre if context->free_cbk is not NULL, it should point to a function that + * deallocates memory + */ +void finalize(const argon2_context *context, argon2_instance_t *instance); + +/* + * Function that fills the segment using previous segments also from other + * threads + * @param context current context + * @param instance Pointer to the current instance + * @param position Current position + * @pre all block pointers must be valid + */ +void fill_segment(const argon2_instance_t *instance, + argon2_position_t position); + +/* + * Function that fills the entire memory t_cost times based on the first two + * blocks in each lane + * @param instance Pointer to the current instance + * @return ARGON2_OK if successful, @context->state + */ +int fill_memory_blocks(argon2_instance_t *instance); + +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/encoding.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/encoding.c new file mode 100644 index 0000000000..64843cde83 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/encoding.c @@ -0,0 +1,461 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include +#include +#include "encoding.h" +#include "core.h" + +/* + * Example code for a decoder and encoder of "hash strings", with Argon2 + * parameters. + * + * This code comprises three sections: + * + * -- The first section contains generic Base64 encoding and decoding + * functions. It is conceptually applicable to any hash function + * implementation that uses Base64 to encode and decode parameters, + * salts and outputs. It could be made into a library, provided that + * the relevant functions are made public (non-static) and be given + * reasonable names to avoid collisions with other functions. + * + * -- The second section is specific to Argon2. It encodes and decodes + * the parameters, salts and outputs. It does not compute the hash + * itself. + * + * The code was originally written by Thomas Pornin , + * to whom comments and remarks may be sent. It is released under what + * should amount to Public Domain or its closest equivalent; the + * following mantra is supposed to incarnate that fact with all the + * proper legal rituals: + * + * --------------------------------------------------------------------- + * This file is provided under the terms of Creative Commons CC0 1.0 + * Public Domain Dedication. To the extent possible under law, the + * author (Thomas Pornin) has waived all copyright and related or + * neighboring rights to this file. This work is published from: Canada. + * --------------------------------------------------------------------- + * + * Copyright (c) 2015 Thomas Pornin + */ + +/* ==================================================================== */ +/* + * Common code; could be shared between different hash functions. + * + * Note: the Base64 functions below assume that uppercase letters (resp. + * lowercase letters) have consecutive numerical codes, that fit on 8 + * bits. All modern systems use ASCII-compatible charsets, where these + * properties are true. If you are stuck with a dinosaur of a system + * that still defaults to EBCDIC then you already have much bigger + * interoperability issues to deal with. + */ + +/* + * Some macros for constant-time comparisons. These work over values in + * the 0..255 range. Returned value is 0x00 on "false", 0xFF on "true". + */ +#define EQ(x, y) ((((0U - ((unsigned)(x) ^ (unsigned)(y))) >> 8) & 0xFF) ^ 0xFF) +#define GT(x, y) ((((unsigned)(y) - (unsigned)(x)) >> 8) & 0xFF) +#define GE(x, y) (GT(y, x) ^ 0xFF) +#define LT(x, y) GT(y, x) +#define LE(x, y) GE(y, x) + +/* + * Convert value x (0..63) to corresponding Base64 character. + */ +static int b64_byte_to_char(unsigned x) { + return (LT(x, 26) & (x + 'A')) | + (GE(x, 26) & LT(x, 52) & (x + ('a' - 26))) | + (GE(x, 52) & LT(x, 62) & (x + ('0' - 52))) | (EQ(x, 62) & '+') | + (EQ(x, 63) & '/'); +} +/* + * Convert character c to the corresponding 6-bit value. If character c + * is not a Base64 character, then 0xFF (255) is returned. + */ +static unsigned b64_char_to_byte(int c) { + unsigned x; + + x = (GE(c, 'A') & LE(c, 'Z') & (c - 'A')) | + (GE(c, 'a') & LE(c, 'z') & (c - ('a' - 26))) | + (GE(c, '0') & LE(c, '9') & (c - ('0' - 52))) | (EQ(c, '+') & 62) | + (EQ(c, '/') & 63); + return x | (EQ(x, 0) & (EQ(c, 'A') ^ 0xFF)); +} + +/* + * Convert some bytes to Base64. 'dst_len' is the length (in characters) + * of the output buffer 'dst'; if that buffer is not large enough to + * receive the result (including the terminating 0), then (size_t)-1 + * is returned. Otherwise, the zero-terminated Base64 string is written + * in the buffer, and the output length (counted WITHOUT the terminating + * zero) is returned. + */ +static size_t to_base64(char *dst, size_t dst_len, const void *src, + size_t src_len) { + size_t olen; + const unsigned char *buf; + unsigned acc, acc_len; + + olen = (src_len / 3) << 2; + switch (src_len % 3) { + case 2: + olen++; + /* fall through */ + case 1: + olen += 2; + break; + } + if (dst_len <= olen) { + return (size_t)-1; + } + acc = 0; + acc_len = 0; + buf = (const unsigned char *)src; + while (src_len-- > 0) { + acc = (acc << 8) + (*buf++); + acc_len += 8; + while (acc_len >= 6) { + acc_len -= 6; + *dst++ = (char)b64_byte_to_char((acc >> acc_len) & 0x3F); + } + } + if (acc_len > 0) { + *dst++ = (char)b64_byte_to_char((acc << (6 - acc_len)) & 0x3F); + } + *dst++ = 0; + return olen; +} + +/* + * Decode Base64 chars into bytes. The '*dst_len' value must initially + * contain the length of the output buffer '*dst'; when the decoding + * ends, the actual number of decoded bytes is written back in + * '*dst_len'. + * + * Decoding stops when a non-Base64 character is encountered, or when + * the output buffer capacity is exceeded. If an error occurred (output + * buffer is too small, invalid last characters leading to unprocessed + * buffered bits), then NULL is returned; otherwise, the returned value + * points to the first non-Base64 character in the source stream, which + * may be the terminating zero. + */ +static const char *from_base64(void *dst, size_t *dst_len, const char *src) { + size_t len; + unsigned char *buf; + unsigned acc, acc_len; + + buf = (unsigned char *)dst; + len = 0; + acc = 0; + acc_len = 0; + for (;;) { + unsigned d; + + d = b64_char_to_byte(*src); + if (d == 0xFF) { + break; + } + src++; + acc = (acc << 6) + d; + acc_len += 6; + if (acc_len >= 8) { + acc_len -= 8; + if ((len++) >= *dst_len) { + return NULL; + } + *buf++ = (acc >> acc_len) & 0xFF; + } + } + + /* + * If the input length is equal to 1 modulo 4 (which is + * invalid), then there will remain 6 unprocessed bits; + * otherwise, only 0, 2 or 4 bits are buffered. The buffered + * bits must also all be zero. + */ + if (acc_len > 4 || (acc & (((unsigned)1 << acc_len) - 1)) != 0) { + return NULL; + } + *dst_len = len; + return src; +} + +/* + * Decode decimal integer from 'str'; the value is written in '*v'. + * Returned value is a pointer to the next non-decimal character in the + * string. If there is no digit at all, or the value encoding is not + * minimal (extra leading zeros), or the value does not fit in an + * 'unsigned long', then NULL is returned. + */ +static const char *decode_decimal(const char *str, unsigned long *v) { + const char *orig; + unsigned long acc; + + acc = 0; + for (orig = str;; str++) { + int c; + + c = *str; + if (c < '0' || c > '9') { + break; + } + c -= '0'; + if (acc > (ULONG_MAX / 10)) { + return NULL; + } + acc *= 10; + if ((unsigned long)c > (ULONG_MAX - acc)) { + return NULL; + } + acc += (unsigned long)c; + } + if (str == orig || (*orig == '0' && str != (orig + 1))) { + return NULL; + } + *v = acc; + return str; +} + +/* ==================================================================== */ +/* + * Code specific to Argon2. + * + * The code below applies the following format: + * + * $argon2[$v=]$m=,t=,p=$$ + * + * where is either 'd', 'id', or 'i', is a decimal integer (positive, + * fits in an 'unsigned long'), and is Base64-encoded data (no '=' padding + * characters, no newline or whitespace). + * + * The last two binary chunks (encoded in Base64) are, in that order, + * the salt and the output. Both are required. The binary salt length and the + * output length must be in the allowed ranges defined in argon2.h. + * + * The ctx struct must contain buffers large enough to hold the salt and pwd + * when it is fed into decode_string. + */ + +int decode_string(argon2_context *ctx, const char *str, argon2_type type) { + +/* check for prefix */ +#define CC(prefix) \ + do { \ + size_t cc_len = strlen(prefix); \ + if (strncmp(str, prefix, cc_len) != 0) { \ + return ARGON2_DECODING_FAIL; \ + } \ + str += cc_len; \ + } while ((void)0, 0) + +/* optional prefix checking with supplied code */ +#define CC_opt(prefix, code) \ + do { \ + size_t cc_len = strlen(prefix); \ + if (strncmp(str, prefix, cc_len) == 0) { \ + str += cc_len; \ + { code; } \ + } \ + } while ((void)0, 0) + +/* Decoding prefix into decimal */ +#define DECIMAL(x) \ + do { \ + unsigned long dec_x; \ + str = decode_decimal(str, &dec_x); \ + if (str == NULL) { \ + return ARGON2_DECODING_FAIL; \ + } \ + (x) = dec_x; \ + } while ((void)0, 0) + + +/* Decoding prefix into uint32_t decimal */ +#define DECIMAL_U32(x) \ + do { \ + unsigned long dec_x; \ + str = decode_decimal(str, &dec_x); \ + if (str == NULL || dec_x > UINT32_MAX) { \ + return ARGON2_DECODING_FAIL; \ + } \ + (x) = (uint32_t)dec_x; \ + } while ((void)0, 0) + + +/* Decoding base64 into a binary buffer */ +#define BIN(buf, max_len, len) \ + do { \ + size_t bin_len = (max_len); \ + str = from_base64(buf, &bin_len, str); \ + if (str == NULL || bin_len > UINT32_MAX) { \ + return ARGON2_DECODING_FAIL; \ + } \ + (len) = (uint32_t)bin_len; \ + } while ((void)0, 0) + + size_t maxsaltlen = ctx->saltlen; + size_t maxoutlen = ctx->outlen; + int validation_result; + const char* type_string; + + /* We should start with the argon2_type we are using */ + type_string = argon2_type2string(type, 0); + if (!type_string) { + return ARGON2_INCORRECT_TYPE; + } + + CC("$"); + CC(type_string); + + /* Reading the version number if the default is suppressed */ + ctx->version = ARGON2_VERSION_10; + CC_opt("$v=", DECIMAL_U32(ctx->version)); + + CC("$m="); + DECIMAL_U32(ctx->m_cost); + CC(",t="); + DECIMAL_U32(ctx->t_cost); + CC(",p="); + DECIMAL_U32(ctx->lanes); + ctx->threads = ctx->lanes; + + CC("$"); + BIN(ctx->salt, maxsaltlen, ctx->saltlen); + CC("$"); + BIN(ctx->out, maxoutlen, ctx->outlen); + + /* The rest of the fields get the default values */ + ctx->secret = NULL; + ctx->secretlen = 0; + ctx->ad = NULL; + ctx->adlen = 0; + ctx->allocate_cbk = NULL; + ctx->free_cbk = NULL; + ctx->flags = ARGON2_DEFAULT_FLAGS; + + /* On return, must have valid context */ + validation_result = validate_inputs(ctx); + if (validation_result != ARGON2_OK) { + return validation_result; + } + + /* Can't have any additional characters */ + if (*str == 0) { + return ARGON2_OK; + } else { + return ARGON2_DECODING_FAIL; + } +#undef CC +#undef CC_opt +#undef DECIMAL +#undef BIN +} + +int encode_string(char *dst, size_t dst_len, argon2_context *ctx, + argon2_type type) { +#define SS(str) \ + do { \ + size_t pp_len = strlen(str); \ + if (pp_len >= dst_len) { \ + return ARGON2_ENCODING_FAIL; \ + } \ + memcpy(dst, str, pp_len + 1); \ + dst += pp_len; \ + dst_len -= pp_len; \ + } while ((void)0, 0) + +#define SX(x) \ + do { \ + char tmp[30]; \ + sprintf(tmp, "%lu", (unsigned long)(x)); \ + SS(tmp); \ + } while ((void)0, 0) + +#define SB(buf, len) \ + do { \ + size_t sb_len = to_base64(dst, dst_len, buf, len); \ + if (sb_len == (size_t)-1) { \ + return ARGON2_ENCODING_FAIL; \ + } \ + dst += sb_len; \ + dst_len -= sb_len; \ + } while ((void)0, 0) + + const char* type_string = argon2_type2string(type, 0); + int validation_result = validate_inputs(ctx); + + if (!type_string) { + return ARGON2_ENCODING_FAIL; + } + + if (validation_result != ARGON2_OK) { + return validation_result; + } + + + SS("$"); + SS(type_string); + + SS("$v="); + SX(ctx->version); + + SS("$m="); + SX(ctx->m_cost); + SS(",t="); + SX(ctx->t_cost); + SS(",p="); + SX(ctx->lanes); + + SS("$"); + SB(ctx->salt, ctx->saltlen); + + SS("$"); + SB(ctx->out, ctx->outlen); + return ARGON2_OK; + +#undef SS +#undef SX +#undef SB +} + +size_t b64len(uint32_t len) { + size_t olen = ((size_t)len / 3) << 2; + + switch (len % 3) { + case 2: + olen++; + /* fall through */ + case 1: + olen += 2; + break; + } + + return olen; +} + +size_t numlen(uint32_t num) { + size_t len = 1; + while (num >= 10) { + ++len; + num = num / 10; + } + return len; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/encoding.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/encoding.h new file mode 100644 index 0000000000..5b8b2ddb45 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/encoding.h @@ -0,0 +1,57 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ENCODING_H +#define ENCODING_H +#include "argon2.h" + +#define ARGON2_MAX_DECODED_LANES UINT32_C(255) +#define ARGON2_MIN_DECODED_SALT_LEN UINT32_C(8) +#define ARGON2_MIN_DECODED_OUT_LEN UINT32_C(12) + +/* +* encode an Argon2 hash string into the provided buffer. 'dst_len' +* contains the size, in characters, of the 'dst' buffer; if 'dst_len' +* is less than the number of required characters (including the +* terminating 0), then this function returns ARGON2_ENCODING_ERROR. +* +* on success, ARGON2_OK is returned. +*/ +int encode_string(char *dst, size_t dst_len, argon2_context *ctx, + argon2_type type); + +/* +* Decodes an Argon2 hash string into the provided structure 'ctx'. +* The only fields that must be set prior to this call are ctx.saltlen and +* ctx.outlen (which must be the maximal salt and out length values that are +* allowed), ctx.salt and ctx.out (which must be buffers of the specified +* length), and ctx.pwd and ctx.pwdlen which must hold a valid password. +* +* Invalid input string causes an error. On success, the ctx is valid and all +* fields have been initialized. +* +* Returned value is ARGON2_OK on success, other ARGON2_ codes on error. +*/ +int decode_string(argon2_context *ctx, const char *str, argon2_type type); + +/* Returns the length of the encoded byte stream with length len */ +size_t b64len(uint32_t len); + +/* Returns the length of the encoded number num */ +size_t numlen(uint32_t num); + +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/opt.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/opt.c new file mode 100644 index 0000000000..6c5e403fcf --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/opt.c @@ -0,0 +1,283 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "argon2.h" +#include "core.h" + +#include "blake2/blake2.h" +#include "blake2/blamka-round-opt.h" + +/* + * Function fills a new memory block and optionally XORs the old block over the new one. + * Memory must be initialized. + * @param state Pointer to the just produced block. Content will be updated(!) + * @param ref_block Pointer to the reference block + * @param next_block Pointer to the block to be XORed over. May coincide with @ref_block + * @param with_xor Whether to XOR into the new block (1) or just overwrite (0) + * @pre all block pointers must be valid + */ +#if defined(__AVX512F__) +static void fill_block(__m512i *state, const block *ref_block, + block *next_block, int with_xor) { + __m512i block_XY[ARGON2_512BIT_WORDS_IN_BLOCK]; + unsigned int i; + + if (with_xor) { + for (i = 0; i < ARGON2_512BIT_WORDS_IN_BLOCK; i++) { + state[i] = _mm512_xor_si512( + state[i], _mm512_loadu_si512((const __m512i *)ref_block->v + i)); + block_XY[i] = _mm512_xor_si512( + state[i], _mm512_loadu_si512((const __m512i *)next_block->v + i)); + } + } else { + for (i = 0; i < ARGON2_512BIT_WORDS_IN_BLOCK; i++) { + block_XY[i] = state[i] = _mm512_xor_si512( + state[i], _mm512_loadu_si512((const __m512i *)ref_block->v + i)); + } + } + + for (i = 0; i < 2; ++i) { + BLAKE2_ROUND_1( + state[8 * i + 0], state[8 * i + 1], state[8 * i + 2], state[8 * i + 3], + state[8 * i + 4], state[8 * i + 5], state[8 * i + 6], state[8 * i + 7]); + } + + for (i = 0; i < 2; ++i) { + BLAKE2_ROUND_2( + state[2 * 0 + i], state[2 * 1 + i], state[2 * 2 + i], state[2 * 3 + i], + state[2 * 4 + i], state[2 * 5 + i], state[2 * 6 + i], state[2 * 7 + i]); + } + + for (i = 0; i < ARGON2_512BIT_WORDS_IN_BLOCK; i++) { + state[i] = _mm512_xor_si512(state[i], block_XY[i]); + _mm512_storeu_si512((__m512i *)next_block->v + i, state[i]); + } +} +#elif defined(__AVX2__) +static void fill_block(__m256i *state, const block *ref_block, + block *next_block, int with_xor) { + __m256i block_XY[ARGON2_HWORDS_IN_BLOCK]; + unsigned int i; + + if (with_xor) { + for (i = 0; i < ARGON2_HWORDS_IN_BLOCK; i++) { + state[i] = _mm256_xor_si256( + state[i], _mm256_loadu_si256((const __m256i *)ref_block->v + i)); + block_XY[i] = _mm256_xor_si256( + state[i], _mm256_loadu_si256((const __m256i *)next_block->v + i)); + } + } else { + for (i = 0; i < ARGON2_HWORDS_IN_BLOCK; i++) { + block_XY[i] = state[i] = _mm256_xor_si256( + state[i], _mm256_loadu_si256((const __m256i *)ref_block->v + i)); + } + } + + for (i = 0; i < 4; ++i) { + BLAKE2_ROUND_1(state[8 * i + 0], state[8 * i + 4], state[8 * i + 1], state[8 * i + 5], + state[8 * i + 2], state[8 * i + 6], state[8 * i + 3], state[8 * i + 7]); + } + + for (i = 0; i < 4; ++i) { + BLAKE2_ROUND_2(state[ 0 + i], state[ 4 + i], state[ 8 + i], state[12 + i], + state[16 + i], state[20 + i], state[24 + i], state[28 + i]); + } + + for (i = 0; i < ARGON2_HWORDS_IN_BLOCK; i++) { + state[i] = _mm256_xor_si256(state[i], block_XY[i]); + _mm256_storeu_si256((__m256i *)next_block->v + i, state[i]); + } +} +#else +static void fill_block(__m128i *state, const block *ref_block, + block *next_block, int with_xor) { + __m128i block_XY[ARGON2_OWORDS_IN_BLOCK]; + unsigned int i; + + if (with_xor) { + for (i = 0; i < ARGON2_OWORDS_IN_BLOCK; i++) { + state[i] = _mm_xor_si128( + state[i], _mm_loadu_si128((const __m128i *)ref_block->v + i)); + block_XY[i] = _mm_xor_si128( + state[i], _mm_loadu_si128((const __m128i *)next_block->v + i)); + } + } else { + for (i = 0; i < ARGON2_OWORDS_IN_BLOCK; i++) { + block_XY[i] = state[i] = _mm_xor_si128( + state[i], _mm_loadu_si128((const __m128i *)ref_block->v + i)); + } + } + + for (i = 0; i < 8; ++i) { + BLAKE2_ROUND(state[8 * i + 0], state[8 * i + 1], state[8 * i + 2], + state[8 * i + 3], state[8 * i + 4], state[8 * i + 5], + state[8 * i + 6], state[8 * i + 7]); + } + + for (i = 0; i < 8; ++i) { + BLAKE2_ROUND(state[8 * 0 + i], state[8 * 1 + i], state[8 * 2 + i], + state[8 * 3 + i], state[8 * 4 + i], state[8 * 5 + i], + state[8 * 6 + i], state[8 * 7 + i]); + } + + for (i = 0; i < ARGON2_OWORDS_IN_BLOCK; i++) { + state[i] = _mm_xor_si128(state[i], block_XY[i]); + _mm_storeu_si128((__m128i *)next_block->v + i, state[i]); + } +} +#endif + +static void next_addresses(block *address_block, block *input_block) { + /*Temporary zero-initialized blocks*/ +#if defined(__AVX512F__) + __m512i zero_block[ARGON2_512BIT_WORDS_IN_BLOCK]; + __m512i zero2_block[ARGON2_512BIT_WORDS_IN_BLOCK]; +#elif defined(__AVX2__) + __m256i zero_block[ARGON2_HWORDS_IN_BLOCK]; + __m256i zero2_block[ARGON2_HWORDS_IN_BLOCK]; +#else + __m128i zero_block[ARGON2_OWORDS_IN_BLOCK]; + __m128i zero2_block[ARGON2_OWORDS_IN_BLOCK]; +#endif + + memset(zero_block, 0, sizeof(zero_block)); + memset(zero2_block, 0, sizeof(zero2_block)); + + /*Increasing index counter*/ + input_block->v[6]++; + + /*First iteration of G*/ + fill_block(zero_block, input_block, address_block, 0); + + /*Second iteration of G*/ + fill_block(zero2_block, address_block, address_block, 0); +} + +void fill_segment(const argon2_instance_t *instance, + argon2_position_t position) { + block *ref_block = NULL, *curr_block = NULL; + block address_block, input_block; + uint64_t pseudo_rand, ref_index, ref_lane; + uint32_t prev_offset, curr_offset; + uint32_t starting_index, i; +#if defined(__AVX512F__) + __m512i state[ARGON2_512BIT_WORDS_IN_BLOCK]; +#elif defined(__AVX2__) + __m256i state[ARGON2_HWORDS_IN_BLOCK]; +#else + __m128i state[ARGON2_OWORDS_IN_BLOCK]; +#endif + int data_independent_addressing; + + if (instance == NULL) { + return; + } + + data_independent_addressing = + (instance->type == Argon2_i) || + (instance->type == Argon2_id && (position.pass == 0) && + (position.slice < ARGON2_SYNC_POINTS / 2)); + + if (data_independent_addressing) { + init_block_value(&input_block, 0); + + input_block.v[0] = position.pass; + input_block.v[1] = position.lane; + input_block.v[2] = position.slice; + input_block.v[3] = instance->memory_blocks; + input_block.v[4] = instance->passes; + input_block.v[5] = instance->type; + } + + starting_index = 0; + + if ((0 == position.pass) && (0 == position.slice)) { + starting_index = 2; /* we have already generated the first two blocks */ + + /* Don't forget to generate the first block of addresses: */ + if (data_independent_addressing) { + next_addresses(&address_block, &input_block); + } + } + + /* Offset of the current block */ + curr_offset = position.lane * instance->lane_length + + position.slice * instance->segment_length + starting_index; + + if (0 == curr_offset % instance->lane_length) { + /* Last block in this lane */ + prev_offset = curr_offset + instance->lane_length - 1; + } else { + /* Previous block */ + prev_offset = curr_offset - 1; + } + + memcpy(state, ((instance->memory + prev_offset)->v), ARGON2_BLOCK_SIZE); + + for (i = starting_index; i < instance->segment_length; + ++i, ++curr_offset, ++prev_offset) { + /*1.1 Rotating prev_offset if needed */ + if (curr_offset % instance->lane_length == 1) { + prev_offset = curr_offset - 1; + } + + /* 1.2 Computing the index of the reference block */ + /* 1.2.1 Taking pseudo-random value from the previous block */ + if (data_independent_addressing) { + if (i % ARGON2_ADDRESSES_IN_BLOCK == 0) { + next_addresses(&address_block, &input_block); + } + pseudo_rand = address_block.v[i % ARGON2_ADDRESSES_IN_BLOCK]; + } else { + pseudo_rand = instance->memory[prev_offset].v[0]; + } + + /* 1.2.2 Computing the lane of the reference block */ + ref_lane = ((pseudo_rand >> 32)) % instance->lanes; + + if ((position.pass == 0) && (position.slice == 0)) { + /* Can not reference other lanes yet */ + ref_lane = position.lane; + } + + /* 1.2.3 Computing the number of possible reference block within the + * lane. + */ + position.index = i; + ref_index = index_alpha(instance, &position, pseudo_rand & 0xFFFFFFFF, + ref_lane == position.lane); + + /* 2 Creating a new block */ + ref_block = + instance->memory + instance->lane_length * ref_lane + ref_index; + curr_block = instance->memory + curr_offset; + if (ARGON2_VERSION_10 == instance->version) { + /* version 1.2.1 and earlier: overwrite, not XOR */ + fill_block(state, ref_block, curr_block, 0); + } else { + if(0 == position.pass) { + fill_block(state, ref_block, curr_block, 0); + } else { + fill_block(state, ref_block, curr_block, 1); + } + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/ref.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/ref.c new file mode 100644 index 0000000000..10e45eba64 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/ref.c @@ -0,0 +1,194 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "argon2.h" +#include "core.h" + +#include "blake2/blamka-round-ref.h" +#include "blake2/blake2-impl.h" +#include "blake2/blake2.h" + + +/* + * Function fills a new memory block and optionally XORs the old block over the new one. + * @next_block must be initialized. + * @param prev_block Pointer to the previous block + * @param ref_block Pointer to the reference block + * @param next_block Pointer to the block to be constructed + * @param with_xor Whether to XOR into the new block (1) or just overwrite (0) + * @pre all block pointers must be valid + */ +static void fill_block(const block *prev_block, const block *ref_block, + block *next_block, int with_xor) { + block blockR, block_tmp; + unsigned i; + + copy_block(&blockR, ref_block); + xor_block(&blockR, prev_block); + copy_block(&block_tmp, &blockR); + /* Now blockR = ref_block + prev_block and block_tmp = ref_block + prev_block */ + if (with_xor) { + /* Saving the next block contents for XOR over: */ + xor_block(&block_tmp, next_block); + /* Now blockR = ref_block + prev_block and + block_tmp = ref_block + prev_block + next_block */ + } + + /* Apply Blake2 on columns of 64-bit words: (0,1,...,15) , then + (16,17,..31)... finally (112,113,...127) */ + for (i = 0; i < 8; ++i) { + BLAKE2_ROUND_NOMSG( + blockR.v[16 * i], blockR.v[16 * i + 1], blockR.v[16 * i + 2], + blockR.v[16 * i + 3], blockR.v[16 * i + 4], blockR.v[16 * i + 5], + blockR.v[16 * i + 6], blockR.v[16 * i + 7], blockR.v[16 * i + 8], + blockR.v[16 * i + 9], blockR.v[16 * i + 10], blockR.v[16 * i + 11], + blockR.v[16 * i + 12], blockR.v[16 * i + 13], blockR.v[16 * i + 14], + blockR.v[16 * i + 15]); + } + + /* Apply Blake2 on rows of 64-bit words: (0,1,16,17,...112,113), then + (2,3,18,19,...,114,115).. finally (14,15,30,31,...,126,127) */ + for (i = 0; i < 8; i++) { + BLAKE2_ROUND_NOMSG( + blockR.v[2 * i], blockR.v[2 * i + 1], blockR.v[2 * i + 16], + blockR.v[2 * i + 17], blockR.v[2 * i + 32], blockR.v[2 * i + 33], + blockR.v[2 * i + 48], blockR.v[2 * i + 49], blockR.v[2 * i + 64], + blockR.v[2 * i + 65], blockR.v[2 * i + 80], blockR.v[2 * i + 81], + blockR.v[2 * i + 96], blockR.v[2 * i + 97], blockR.v[2 * i + 112], + blockR.v[2 * i + 113]); + } + + copy_block(next_block, &block_tmp); + xor_block(next_block, &blockR); +} + +static void next_addresses(block *address_block, block *input_block, + const block *zero_block) { + input_block->v[6]++; + fill_block(zero_block, input_block, address_block, 0); + fill_block(zero_block, address_block, address_block, 0); +} + +void fill_segment(const argon2_instance_t *instance, + argon2_position_t position) { + block *ref_block = NULL, *curr_block = NULL; + block address_block, input_block, zero_block; + uint64_t pseudo_rand, ref_index, ref_lane; + uint32_t prev_offset, curr_offset; + uint32_t starting_index; + uint32_t i; + int data_independent_addressing; + + if (instance == NULL) { + return; + } + + data_independent_addressing = + (instance->type == Argon2_i) || + (instance->type == Argon2_id && (position.pass == 0) && + (position.slice < ARGON2_SYNC_POINTS / 2)); + + if (data_independent_addressing) { + init_block_value(&zero_block, 0); + init_block_value(&input_block, 0); + + input_block.v[0] = position.pass; + input_block.v[1] = position.lane; + input_block.v[2] = position.slice; + input_block.v[3] = instance->memory_blocks; + input_block.v[4] = instance->passes; + input_block.v[5] = instance->type; + } + + starting_index = 0; + + if ((0 == position.pass) && (0 == position.slice)) { + starting_index = 2; /* we have already generated the first two blocks */ + + /* Don't forget to generate the first block of addresses: */ + if (data_independent_addressing) { + next_addresses(&address_block, &input_block, &zero_block); + } + } + + /* Offset of the current block */ + curr_offset = position.lane * instance->lane_length + + position.slice * instance->segment_length + starting_index; + + if (0 == curr_offset % instance->lane_length) { + /* Last block in this lane */ + prev_offset = curr_offset + instance->lane_length - 1; + } else { + /* Previous block */ + prev_offset = curr_offset - 1; + } + + for (i = starting_index; i < instance->segment_length; + ++i, ++curr_offset, ++prev_offset) { + /*1.1 Rotating prev_offset if needed */ + if (curr_offset % instance->lane_length == 1) { + prev_offset = curr_offset - 1; + } + + /* 1.2 Computing the index of the reference block */ + /* 1.2.1 Taking pseudo-random value from the previous block */ + if (data_independent_addressing) { + if (i % ARGON2_ADDRESSES_IN_BLOCK == 0) { + next_addresses(&address_block, &input_block, &zero_block); + } + pseudo_rand = address_block.v[i % ARGON2_ADDRESSES_IN_BLOCK]; + } else { + pseudo_rand = instance->memory[prev_offset].v[0]; + } + + /* 1.2.2 Computing the lane of the reference block */ + ref_lane = ((pseudo_rand >> 32)) % instance->lanes; + + if ((position.pass == 0) && (position.slice == 0)) { + /* Can not reference other lanes yet */ + ref_lane = position.lane; + } + + /* 1.2.3 Computing the number of possible reference block within the + * lane. + */ + position.index = i; + ref_index = index_alpha(instance, &position, pseudo_rand & 0xFFFFFFFF, + ref_lane == position.lane); + + /* 2 Creating a new block */ + ref_block = + instance->memory + instance->lane_length * ref_lane + ref_index; + curr_block = instance->memory + curr_offset; + if (ARGON2_VERSION_10 == instance->version) { + /* version 1.2.1 and earlier: overwrite, not XOR */ + fill_block(instance->memory + prev_offset, ref_block, curr_block, 0); + } else { + if(0 == position.pass) { + fill_block(instance->memory + prev_offset, ref_block, + curr_block, 0); + } else { + fill_block(instance->memory + prev_offset, ref_block, + curr_block, 1); + } + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/run.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/run.c new file mode 100644 index 0000000000..e6ac4bbe30 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/run.c @@ -0,0 +1,335 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#define _GNU_SOURCE 1 + +#include +#include +#include +#include +#include + +#include "argon2.h" +#include "core.h" + +#define T_COST_DEF 3 +#define LOG_M_COST_DEF 12 /* 2^12 = 4 MiB */ +#define LANES_DEF 1 +#define THREADS_DEF 1 +#define OUTLEN_DEF 32 +#define MAX_PASS_LEN 128 + +#define UNUSED_PARAMETER(x) (void)(x) + +static void usage(const char *cmd) { + printf("Usage: %s [-h] salt [-i|-d|-id] [-t iterations] " + "[-m log2(memory in KiB) | -k memory in KiB] [-p parallelism] " + "[-l hash length] [-e|-r] [-v (10|13)]\n", + cmd); + printf("\tPassword is read from stdin\n"); + printf("Parameters:\n"); + printf("\tsalt\t\tThe salt to use, at least 8 characters\n"); + printf("\t-i\t\tUse Argon2i (this is the default)\n"); + printf("\t-d\t\tUse Argon2d instead of Argon2i\n"); + printf("\t-id\t\tUse Argon2id instead of Argon2i\n"); + printf("\t-t N\t\tSets the number of iterations to N (default = %d)\n", + T_COST_DEF); + printf("\t-m N\t\tSets the memory usage of 2^N KiB (default %d)\n", + LOG_M_COST_DEF); + printf("\t-k N\t\tSets the memory usage of N KiB (default %d)\n", + 1 << LOG_M_COST_DEF); + printf("\t-p N\t\tSets parallelism to N threads (default %d)\n", + THREADS_DEF); + printf("\t-l N\t\tSets hash output length to N bytes (default %d)\n", + OUTLEN_DEF); + printf("\t-e\t\tOutput only encoded hash\n"); + printf("\t-r\t\tOutput only the raw bytes of the hash\n"); + printf("\t-v (10|13)\tArgon2 version (defaults to the most recent version, currently %x)\n", + ARGON2_VERSION_NUMBER); + printf("\t-h\t\tPrint %s usage\n", cmd); +} +static void fatal(const char *error) { + fprintf(stderr, "Error: %s\n", error); + exit(1); +} + +static void print_hex(uint8_t *bytes, size_t bytes_len) { + size_t i; + for (i = 0; i < bytes_len; ++i) { + printf("%02x", bytes[i]); + } + printf("\n"); +} + +/* +Runs Argon2 with certain inputs and parameters, inputs not cleared. Prints the +Base64-encoded hash string +@out output array with at least 32 bytes allocated +@pwd NULL-terminated string, presumably from argv[] +@salt salt array +@t_cost number of iterations +@m_cost amount of requested memory in KB +@lanes amount of requested parallelism +@threads actual parallelism +@type Argon2 type we want to run +@encoded_only display only the encoded hash +@raw_only display only the hexadecimal of the hash +@version Argon2 version +*/ +static void run(uint32_t outlen, char *pwd, size_t pwdlen, char *salt, uint32_t t_cost, + uint32_t m_cost, uint32_t lanes, uint32_t threads, + argon2_type type, int encoded_only, int raw_only, uint32_t version) { + clock_t start_time, stop_time; + size_t saltlen, encodedlen; + int result; + unsigned char * out = NULL; + char * encoded = NULL; + + start_time = clock(); + + if (!pwd) { + fatal("password missing"); + } + + if (!salt) { + clear_internal_memory(pwd, pwdlen); + fatal("salt missing"); + } + + saltlen = strlen(salt); + if(UINT32_MAX < saltlen) { + fatal("salt is too long"); + } + + UNUSED_PARAMETER(lanes); + + out = malloc(outlen + 1); + if (!out) { + clear_internal_memory(pwd, pwdlen); + fatal("could not allocate memory for output"); + } + + encodedlen = argon2_encodedlen(t_cost, m_cost, lanes, (uint32_t)saltlen, outlen, type); + encoded = malloc(encodedlen + 1); + if (!encoded) { + clear_internal_memory(pwd, pwdlen); + fatal("could not allocate memory for hash"); + } + + result = argon2_hash(t_cost, m_cost, threads, pwd, pwdlen, salt, saltlen, + out, outlen, encoded, encodedlen, type, + version); + if (result != ARGON2_OK) + fatal(argon2_error_message(result)); + + stop_time = clock(); + + if (encoded_only) + puts(encoded); + + if (raw_only) + print_hex(out, outlen); + + if (encoded_only || raw_only) { + free(out); + free(encoded); + return; + } + + printf("Hash:\t\t"); + print_hex(out, outlen); + free(out); + + printf("Encoded:\t%s\n", encoded); + + printf("%2.3f seconds\n", + ((double)stop_time - start_time) / (CLOCKS_PER_SEC)); + + result = argon2_verify(encoded, pwd, pwdlen, type); + if (result != ARGON2_OK) + fatal(argon2_error_message(result)); + printf("Verification ok\n"); + free(encoded); +} + +int main(int argc, char *argv[]) { + uint32_t outlen = OUTLEN_DEF; + uint32_t m_cost = 1 << LOG_M_COST_DEF; + uint32_t t_cost = T_COST_DEF; + uint32_t lanes = LANES_DEF; + uint32_t threads = THREADS_DEF; + argon2_type type = Argon2_i; /* Argon2i is the default type */ + int types_specified = 0; + int m_cost_specified = 0; + int encoded_only = 0; + int raw_only = 0; + uint32_t version = ARGON2_VERSION_NUMBER; + int i; + size_t pwdlen; + char pwd[MAX_PASS_LEN], *salt; + + if (argc < 2) { + usage(argv[0]); + return ARGON2_MISSING_ARGS; + } else if (argc >= 2 && strcmp(argv[1], "-h") == 0) { + usage(argv[0]); + return 1; + } + + /* get password from stdin */ + pwdlen = fread(pwd, 1, sizeof pwd, stdin); + if(pwdlen < 1) { + fatal("no password read"); + } + if(pwdlen == MAX_PASS_LEN) { + fatal("Provided password longer than supported in command line utility"); + } + + salt = argv[1]; + + /* parse options */ + for (i = 2; i < argc; i++) { + const char *a = argv[i]; + unsigned long input = 0; + if (!strcmp(a, "-h")) { + usage(argv[0]); + return 1; + } else if (!strcmp(a, "-m")) { + if (m_cost_specified) { + fatal("-m or -k can only be used once"); + } + m_cost_specified = 1; + if (i < argc - 1) { + i++; + input = strtoul(argv[i], NULL, 10); + if (input == 0 || input == ULONG_MAX || + input > ARGON2_MAX_MEMORY_BITS) { + fatal("bad numeric input for -m"); + } + m_cost = ARGON2_MIN(UINT64_C(1) << input, UINT32_C(0xFFFFFFFF)); + if (m_cost > ARGON2_MAX_MEMORY) { + fatal("m_cost overflow"); + } + continue; + } else { + fatal("missing -m argument"); + } + } else if (!strcmp(a, "-k")) { + if (m_cost_specified) { + fatal("-m or -k can only be used once"); + } + m_cost_specified = 1; + if (i < argc - 1) { + i++; + input = strtoul(argv[i], NULL, 10); + if (input == 0 || input == ULONG_MAX) { + fatal("bad numeric input for -k"); + } + m_cost = ARGON2_MIN(input, UINT32_C(0xFFFFFFFF)); + if (m_cost > ARGON2_MAX_MEMORY) { + fatal("m_cost overflow"); + } + continue; + } else { + fatal("missing -k argument"); + } + } else if (!strcmp(a, "-t")) { + if (i < argc - 1) { + i++; + input = strtoul(argv[i], NULL, 10); + if (input == 0 || input == ULONG_MAX || + input > ARGON2_MAX_TIME) { + fatal("bad numeric input for -t"); + } + t_cost = input; + continue; + } else { + fatal("missing -t argument"); + } + } else if (!strcmp(a, "-p")) { + if (i < argc - 1) { + i++; + input = strtoul(argv[i], NULL, 10); + if (input == 0 || input == ULONG_MAX || + input > ARGON2_MAX_THREADS || input > ARGON2_MAX_LANES) { + fatal("bad numeric input for -p"); + } + threads = input; + lanes = threads; + continue; + } else { + fatal("missing -p argument"); + } + } else if (!strcmp(a, "-l")) { + if (i < argc - 1) { + i++; + input = strtoul(argv[i], NULL, 10); + outlen = input; + continue; + } else { + fatal("missing -l argument"); + } + } else if (!strcmp(a, "-i")) { + type = Argon2_i; + ++types_specified; + } else if (!strcmp(a, "-d")) { + type = Argon2_d; + ++types_specified; + } else if (!strcmp(a, "-id")) { + type = Argon2_id; + ++types_specified; + } else if (!strcmp(a, "-e")) { + encoded_only = 1; + } else if (!strcmp(a, "-r")) { + raw_only = 1; + } else if (!strcmp(a, "-v")) { + if (i < argc - 1) { + i++; + if (!strcmp(argv[i], "10")) { + version = ARGON2_VERSION_10; + } else if (!strcmp(argv[i], "13")) { + version = ARGON2_VERSION_13; + } else { + fatal("invalid Argon2 version"); + } + } else { + fatal("missing -v argument"); + } + } else { + fatal("unknown argument"); + } + } + + if (types_specified > 1) { + fatal("cannot specify multiple Argon2 types"); + } + + if(encoded_only && raw_only) + fatal("cannot provide both -e and -r"); + + if(!encoded_only && !raw_only) { + printf("Type:\t\t%s\n", argon2_type2string(type, 1)); + printf("Iterations:\t%u\n", t_cost); + printf("Memory:\t\t%u KiB\n", m_cost); + printf("Parallelism:\t%u\n", lanes); + } + + run(outlen, pwd, pwdlen, salt, t_cost, m_cost, lanes, threads, type, + encoded_only, raw_only, version); + + return ARGON2_OK; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/thread.c b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/thread.c new file mode 100644 index 0000000000..3ae2fb2332 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/thread.c @@ -0,0 +1,57 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#if !defined(ARGON2_NO_THREADS) + +#include "thread.h" +#if defined(_WIN32) +#include +#endif + +int argon2_thread_create(argon2_thread_handle_t *handle, + argon2_thread_func_t func, void *args) { + if (NULL == handle || func == NULL) { + return -1; + } +#if defined(_WIN32) + *handle = _beginthreadex(NULL, 0, func, args, 0, NULL); + return *handle != 0 ? 0 : -1; +#else + return pthread_create(handle, NULL, func, args); +#endif +} + +int argon2_thread_join(argon2_thread_handle_t handle) { +#if defined(_WIN32) + if (WaitForSingleObject((HANDLE)handle, INFINITE) == WAIT_OBJECT_0) { + return CloseHandle((HANDLE)handle) != 0 ? 0 : -1; + } + return -1; +#else + return pthread_join(handle, NULL); +#endif +} + +void argon2_thread_exit(void) { +#if defined(_WIN32) + _endthreadex(0); +#else + pthread_exit(NULL); +#endif +} + +#endif /* ARGON2_NO_THREADS */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/thread.h b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/thread.h new file mode 100644 index 0000000000..d4ca10c150 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/argon2/thread.h @@ -0,0 +1,67 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : https://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : https://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ARGON2_THREAD_H +#define ARGON2_THREAD_H + +#if !defined(ARGON2_NO_THREADS) + +/* + Here we implement an abstraction layer for the simpĺe requirements + of the Argon2 code. We only require 3 primitives---thread creation, + joining, and termination---so full emulation of the pthreads API + is unwarranted. Currently we wrap pthreads and Win32 threads. + + The API defines 2 types: the function pointer type, + argon2_thread_func_t, + and the type of the thread handle---argon2_thread_handle_t. +*/ +#if defined(_WIN32) +#include +typedef unsigned(__stdcall *argon2_thread_func_t)(void *); +typedef uintptr_t argon2_thread_handle_t; +#else +#include +typedef void *(*argon2_thread_func_t)(void *); +typedef pthread_t argon2_thread_handle_t; +#endif + +/* Creates a thread + * @param handle pointer to a thread handle, which is the output of this + * function. Must not be NULL. + * @param func A function pointer for the thread's entry point. Must not be + * NULL. + * @param args Pointer that is passed as an argument to @func. May be NULL. + * @return 0 if @handle and @func are valid pointers and a thread is successfully + * created. + */ +int argon2_thread_create(argon2_thread_handle_t *handle, + argon2_thread_func_t func, void *args); + +/* Waits for a thread to terminate + * @param handle Handle to a thread created with argon2_thread_create. + * @return 0 if @handle is a valid handle, and joining completed successfully. +*/ +int argon2_thread_join(argon2_thread_handle_t handle); + +/* Terminate the current thread. Must be run inside a thread created by + * argon2_thread_create. +*/ +void argon2_thread_exit(void); + +#endif /* ARGON2_NO_THREADS */ +#endif diff --git a/src/apps/mobile/harmonyos/entry/src/main/cpp/napi_argon2.cpp b/src/apps/mobile/harmonyos/entry/src/main/cpp/napi_argon2.cpp new file mode 100644 index 0000000000..34dabb5647 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/cpp/napi_argon2.cpp @@ -0,0 +1,67 @@ +#include +#include "napi/native_api.h" +#include "argon2.h" + +static bool readBytes(napi_env env, napi_value value, const uint8_t **data, size_t *length) { + bool isTypedArray = false; + if (napi_is_typedarray(env, value, &isTypedArray) != napi_ok || !isTypedArray) return false; + napi_typedarray_type type; + size_t count = 0; + void *raw = nullptr; + napi_value arrayBuffer; + size_t offset = 0; + if (napi_get_typedarray_info(env, value, &type, &count, &raw, &arrayBuffer, &offset) != napi_ok || + type != napi_uint8_array || raw == nullptr) return false; + *data = static_cast(raw); + *length = count; + return true; +} + +static napi_value Argon2idRaw(napi_env env, napi_callback_info info) { + size_t argc = 5; + napi_value argv[5] = {nullptr}; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr) != napi_ok || argc != 5) return nullptr; + const uint8_t *password = nullptr; + const uint8_t *salt = nullptr; + size_t passwordLength = 0; + size_t saltLength = 0; + if (!readBytes(env, argv[0], &password, &passwordLength) || !readBytes(env, argv[1], &salt, &saltLength)) return nullptr; + uint32_t memory = 0; + uint32_t time = 0; + uint32_t lanes = 0; + if (napi_get_value_uint32(env, argv[2], &memory) != napi_ok || + napi_get_value_uint32(env, argv[3], &time) != napi_ok || + napi_get_value_uint32(env, argv[4], &lanes) != napi_ok) return nullptr; + uint8_t output[32] = {0}; + const int result = argon2id_hash_raw(time, memory, lanes, password, passwordLength, salt, saltLength, output, sizeof(output)); + if (result != ARGON2_OK) return nullptr; + napi_value resultBuffer; + void *resultData = nullptr; + if (napi_create_arraybuffer(env, sizeof(output), &resultData, &resultBuffer) != napi_ok) return nullptr; + napi_value resultArray; + if (napi_create_typedarray(env, napi_uint8_array, sizeof(output), resultBuffer, 0, &resultArray) != napi_ok) return nullptr; + std::memcpy(resultData, output, sizeof(output)); + return resultArray; +} + +static napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor descriptor = {"argon2idRaw", nullptr, Argon2idRaw, nullptr, nullptr, nullptr, napi_default, nullptr}; + napi_define_properties(env, exports, 1, &descriptor); + return exports; +} + +EXTERN_C_START +static napi_module module = { + .nm_version = 1, + .nm_flags = 0, + .nm_filename = nullptr, + .nm_register_func = Init, + .nm_modname = "bitfun_crypto", + .nm_priv = nullptr, + .reserved = {0}, +}; +EXTERN_C_END + +extern "C" __attribute__((constructor)) void RegisterBitfunCrypto(void) { + napi_module_register(&module); +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index b93f3cb9c6..45f8e859a1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -125,6 +125,13 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.actions', '远程设置'], ['remote.device', '连接的设备'], ['remote.newChat', '聊天'], + ['remote.create.chat', '聊天'], + ['remote.create.noDevice', '选择桌面设备'], + ['remote.create.noOnlineDevice', '没有可用的在线桌面设备'], + ['remote.create.placeholder', '告诉 BitFun 要做什么'], + ['remote.create.deviceLoadFailed', '设备列表加载失败,请稍后重试。'], + ['remote.create.workspaceLoadFailed', '工作区加载失败,请稍后重试。'], + ['remote.create.submitFailed', '无法创建会话,请检查桌面连接后重试。'], ['remote.workspace', '工作区'], ['remote.assistant', '助理'], ['remote.noAssistant', '默认助理'], @@ -139,6 +146,8 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.menu.settings', '设置'], ['remote.menu.refreshConnection', '刷新连接'], ['remote.menu.comingSoon', '待上线'], + ['remote.projects.showMore', '再显示 {0} 个项目'], + ['remote.sessions.showMore', '再显示 {0} 条会话'], ['remote.time.today', '今天'], ['remote.time.yesterday', '昨天'], ['remote.time.earlier', '更早'], @@ -151,9 +160,54 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.noDesktop', '尚未连接桌面端'], ['remote.settings.bitfunUser', 'BitFun 用户'], ['remote.settings.profileDetails', '资料'], + ['remote.settings.account', 'BitFun 账号'], + ['remote.settings.accountSignedIn', '已认证'], + ['remote.settings.accountNotSignedIn', '未认证'], + ['remote.settings.accountSignedInBody', '当前连接已通过账号 {0} 验证。密码不会保存到手机。'], + ['remote.settings.accountNotSignedInBody', '扫码连接时,如果桌面端要求账号验证,手机会在本次配对中完成认证。'], + ['remote.settings.accountSignIn', '登录云账号'], + ['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.deviceManagement', '设备管理'], + ['remote.settings.deviceCurrent', '当前设备'], + ['remote.settings.deviceRefresh', '刷新'], + ['remote.settings.deviceLoading', '正在加载设备…'], + ['remote.settings.deviceEmpty', '账号下还没有其他已注册设备。'], + ['remote.settings.deviceLoginRequired', '登录云账号后可查看该账号下的所有设备。'], + ['remote.settings.deviceOnline', '在线'], + ['remote.settings.deviceOffline', '离线'], + ['remote.settings.deviceConnecting', '正在连接'], + ['remote.settings.deviceControlling', '当前控制'], + ['remote.settings.deviceSwitchFailed', '切换控制设备失败,请稍后重试。'], + ['remote.settings.deviceLoadFailed', '设备列表加载失败,请稍后重试。'], + ['remote.settings.accountExpired', '云账号已失效,请重新登录。'], + ['remote.settings.deviceUnavailable', '目标桌面暂时不可用,请刷新设备列表后重试。'], + ['remote.settings.deviceName', '设备'], + ['remote.settings.deviceManagementBody', '此设备的账号凭据已使用 HUKS 加密保存。退出账号后会立即清理。'], ['remote.settings.userId', '用户 ID'], ['remote.settings.deviceId', '本机设备 ID'], ['remote.settings.connectedDesktop', '当前桌面端'], + ['remote.permissions.title', '权限模式'], + ['remote.permissions.scope', '此设置会同步到当前桌面,并应用于所有新工具调用。'], + ['remote.permissions.ask', '需要审批'], + ['remote.permissions.askDescription', '高风险操作会等待你确认。'], + ['remote.permissions.auto', '自动批准'], + ['remote.permissions.autoDescription', '自动通过原本需要确认的操作。'], + ['remote.permissions.fullAccess', '完全访问'], + ['remote.permissions.fullAccessDescription', '允许所有工具操作,不再请求确认。'], + ['remote.permissions.loading', '正在读取桌面权限设置…'], + ['remote.permissions.loadFailed', '权限设置加载失败,请重试。'], + ['remote.permissions.saveFailed', '权限模式保存失败,请重试。'], + ['remote.permissions.connectionRequired', '连接桌面后可修改权限模式。'], + ['remote.permissions.fullAccessWarningTitle', '确认开启完全访问'], + ['remote.permissions.fullAccessWarningBody', '开启后,桌面端工具可以直接执行命令和修改文件。'], + ['remote.permissions.cancel', '取消'], + ['remote.permissions.confirmFullAccess', '开启完全访问'], ['connect.localTitle', '连接本地工作区'], ['connect.subtitle', '连接桌面端后,BitFun 可以访问代码仓库、运行命令和处理本地开发任务。'], @@ -162,6 +216,12 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['connect.obtainPairCodeSteps', '在 BitFun 桌面版侧边栏中,点击“设置 远程控制”以获取配对码。'], ['connect.havePairCode', '我有配对码'], ['connect.scanPairCode', '扫描二维码以配对'], + ['connect.scanPairCodeAction', '扫描二维码连接'], + ['connect.accountDevicesTitle', '选择桌面设备'], + ['connect.accountDevicesSubtitle', 'Remote'], + ['connect.accountDevicesBody', '选择一台在线桌面继续工作。没有看到设备时,可以刷新列表或扫描桌面端二维码。'], + ['connect.availableDevices', '账号设备'], + ['connect.deviceLastUsed', '上次连接'], ['connect.switchManualPair', '改为手动配对'], ['connect.manualPair', '手动配对'], ['connect.manualPairBody', '请输入桌面上显示的配对码。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 72951e8f41..6392a41818 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -85,6 +85,8 @@ export interface CreateSessionOptions { instruction: string; } +export type RemotePermissionMode = 'ask' | 'auto' | 'full_access'; + export interface PairRequest { public_key: string; device_id: string; @@ -121,6 +123,7 @@ export interface RemoteCommand { tool_id?: string; model_id?: string; reason?: string; + mode?: RemotePermissionMode; answers?: Object; image_contexts?: RemoteImageContext[]; images?: ImageAttachment[]; @@ -135,6 +138,10 @@ export interface CommandStatusResponse { message?: string; } +export interface PermissionModeResponse extends CommandStatusResponse { + mode?: RemotePermissionMode; +} + export interface WorkspaceInfoResponse extends CommandStatusResponse { has_workspace?: boolean; workspace_path?: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 3de21ebe98..a2de3319da 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,2591 +1,44 @@ -import { - ChatMessage, - ChatMessageItemResponse, - InitialSyncResult, - RecentWorkspaceEntry, - RemoteModelCatalog, - RemoteImageContext, - RemoteQuestionAnswerPayload, - RemoteSession, - RemoteToolStatusResponse, - SelectedImageAttachment, - SessionSummary, - WorkspaceInfo -} from '../model/RemoteModels'; -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { ClipboardService } from '../services/ClipboardService'; -import { ChatTimelineItem } from '../services/ChatTimelineProjector'; -import { ChatTimelineState, ChatTimelineStore } from '../services/ChatTimelineStore'; -import { ConnectionErrorPolicy, ConnectionErrorResult } from '../services/ConnectionErrorPolicy'; -import { Encoding } from '../services/Encoding'; -import { ImagePickerService } from '../services/ImagePickerService'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigUpdate -} from '../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatBootstrapController } from '../services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandController } from '../services/general-chat/GeneralChatCommandController'; -import { GeneralChatController } from '../services/general-chat/GeneralChatController'; -import { GeneralChatDraftController } from '../services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatStreamLifecycleController } from '../services/general-chat/GeneralChatStreamLifecycleController'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../services/general-chat/GeneralChatServiceState'; -import { - GeneralChatSendResult, - GeneralChatStreamCallbacks -} from '../services/general-chat/GeneralChatPort'; -import { MobileIdentitySnapshot, MobileIdentityStore } from '../services/MobileIdentityStore'; -import { RemoteDescriptorParser } from '../services/RemoteDescriptorParser'; -import { AppRootRouteState } from '../services/AppRootRouteState'; -import { RemoteActivityLifecycleController } from '../services/RemoteActivityLifecycleController'; -import { RemoteChatCommandController } from '../services/RemoteChatCommandController'; -import { - RemoteChatPollingCursor, - RemoteChatPollingLifecycleController, - RemoteChatPollingSnapshot -} from '../services/RemoteChatPollingLifecycleController'; -import { RemoteFileDownloadController } from '../services/RemoteFileDownloadController'; -import { RemoteLogger } from '../services/RemoteLogger'; -import { RemoteModelController } from '../services/RemoteModelController'; -import { RemotePairingPolicy, RemotePairingProjection } from '../services/RemotePairingPolicy'; -import { RemoteSessionController } from '../services/RemoteSessionController'; -import { RemoteSessionManager } from '../services/RemoteSessionManager'; -import { RemoteToolActionController } from '../services/RemoteToolActionController'; -import { QrScanService } from '../services/QrScanService'; -import { RemoteUiState } from '../services/RemoteUiState'; -import { - VoiceInputLifecycleController, - VoiceInputRouteSnapshot -} from '../services/VoiceInputLifecycleController'; -import { VoiceInputService } from '../services/VoiceInputService'; -import { AppShell } from './components/AppShell'; -import { AppSidebar } from './components/AppSidebar'; -import { - GENERAL_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CHAT_COMPOSER_CAPABILITIES -} from './components/ChatComposerCapabilities'; -import { ChatSurface } from './components/ChatSurface'; -import { ConversationView } from './components/ConversationView'; -import { ConnectView } from './components/ConnectView'; -import { RemoteHomeView } from './components/RemoteHomeView'; -import { RemoteControlSettingsSheet } from './components/RemoteControlSettingsSheet'; -import { SettingsSheet } from './components/SettingsSheet'; -import { PAGE_BG } from './components/Theme'; -import { - AppNavigationBackAction, - AppRoute, - AppRouteContract -} from './navigation/AppRouteContract'; -import { AppShellState } from './state/AppShellState'; -import { GeneralChatPageState } from './state/GeneralChatPageState'; -import { RemotePageState } from './state/RemotePageState'; - -enum ConnectionState { - Idle = 'idle', - Parsing = 'parsing', - Pairing = 'pairing', - Connected = 'connected', - Reconnecting = 'reconnecting', - Failed = 'failed', - Disconnected = 'disconnected' -} - -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; -const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; +import { AppRootPresentation } from './components/AppRootPresentation'; +import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; +import { AppRootRuntime } from './state/AppRootRuntime'; @Entry @Component struct AppRoot { - private readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); - private readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); - private readonly clipboardService: ClipboardService = new ClipboardService(); - private readonly qrScanService: QrScanService = new QrScanService(); - private readonly imagePickerService: ImagePickerService = new ImagePickerService(); - private readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); - private readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); - private readonly generalChatController: GeneralChatController = - GeneralChatController.createDefault(this.generalChatConfigStore); - private readonly generalChatDraftController: GeneralChatDraftController = - new GeneralChatDraftController( - this.generalChatController, - GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, - (err: Error) => { - RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); - } - ); - private readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = - new GeneralChatDraftLifecycleController( - this.generalChatDraftController, - GENERAL_CHAT_HOME_DRAFT_ID, - (): string => this.visibleGeneralChatDraftId() - ); - private readonly chatTimelineStore: ChatTimelineStore = new ChatTimelineStore(); - private readonly generalChatCommandController: GeneralChatCommandController = - new GeneralChatCommandController( - this.generalChatController, - { - onSessions: (sessions: RemoteSession[]) => { - this.generalChatPageState.setSessions(sessions); - }, - onSessionPrepared: (sessionId: string) => { - this.resetGeneralChatTimeline(sessionId); - this.remoteModelController.clearCatalog(); - }, - onActiveSession: (session: SessionSummary) => { - this.generalChatPageState.setActiveSession(session); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.syncGeneralChatTimelineFromStore(); - }, - onClearComposer: () => { - this.generalChatPageState.clearComposer(); - }, - onChatInput: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - }, - onBusy: (isBusy: boolean) => { - this.generalChatPageState.setBusy(isBusy); - }, - onToast: (statusText: string) => { - this.showHomeToast(statusText); - } - } - ); - private readonly generalChatBootstrapController: GeneralChatBootstrapController = - new GeneralChatBootstrapController( - this.generalChatConfigStore, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - this.applyGeneralChatConfig(snapshot); - }, - onHomeDraftRestored: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - } - } - ); - private readonly voiceInputService: VoiceInputService = new VoiceInputService(); - private readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = - new RemoteActivityLifecycleController(() => { - this.checkConnectionHealth(); - }); - private deviceId: string = Encoding.randomId('harmony'); - private autoReconnectAttempted: boolean = false; - private isSyncingAfterTurn: boolean = false; - private knownPollVersion: number = 0; - private knownModelCatalogVersion: number = 0; - private knownRemoteMessageCount: number = 0; - private readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = - new GeneralChatStreamLifecycleController(); - private readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); - private readonly remotePageState: RemotePageState = new RemotePageState(); - private remoteWorkspaceSessions: RemoteSession[] = []; - private readonly appShellState: AppShellState = new AppShellState(); - private readonly voiceInputLifecycleController: VoiceInputLifecycleController = - new VoiceInputLifecycleController( - this.voiceInputService, - { - currentInputText: (): string => this.visibleChatInput(), - currentStatusText: (): string => this.visibleStatusText(), - onInputText: (routeId: string, text: string) => { - this.setChatInputForRoute(routeId as AppRoute, text); - }, - onListening: (routeId: string, isListening: boolean) => { - this.setVoiceListeningForRoute(routeId as AppRoute, isListening); - }, - onStatusText: (statusText: string) => { - this.setVisibleStatusText(statusText); - }, - onError: (message: string) => { - this.showVoiceInputError(message); - } - } - ); - private readonly remoteSessionController: RemoteSessionController = - new RemoteSessionController( - this.sessionManager, - 8, - { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { - return item.workspacePath !== this.workspacePath; - }); - this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); - }, - onActiveSession: (session: SessionSummary) => { - this.activeSession = session; - this.remotePageState.setActiveSession(session); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onLoading: (isLoading: boolean) => { - this.remotePageState.setLoading(isLoading); - }, - onSessionError: (errorText: string) => { - this.remotePageState.setError(errorText); - }, - onReconnecting: () => { - this.setRemoteConnectionState(ConnectionState.Reconnecting); - }, - onConnected: () => { - this.setRemoteConnectionState(ConnectionState.Connected); - }, - onConnectionFailed: (err: Object) => { - this.failRemoteConnection(err); - }, - onStartHeartbeat: () => { - this.startHeartbeat(); - } - } - ); - private readonly remoteChatCommandController: RemoteChatCommandController = - new RemoteChatCommandController( - this.sessionManager, - { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.hasMoreMessages = hasMoreMessages; - this.syncChatTimelineFromStore(); - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - this.knownRemoteMessageCount = knownMessageCount; - this.updateChatPollingCursor(pollVersion, knownMessageCount); - }, - onSendSucceeded: (turnId: string, pendingActiveId: string) => { - if (turnId.length > 0) { - this.chatTimelineStore.setLocalActiveTurn(turnId); - this.syncChatTimelineFromStore(); - } else if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - this.syncChatTimelineFromStore(); - } - this.nudgeChatPolling(); - }, - onSendFailed: ( - rawText: string, - images: SelectedImageAttachment[], - localMessageId: string, - pendingActiveId: string - ) => { - this.remotePageState.setChatInput(rawText); - this.remotePageState.setSelectedImages(images); - this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); - if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - } - this.syncChatTimelineFromStore(); - }, - onActiveSession: (session: SessionSummary) => { - this.activeSession = session; - this.remotePageState.setActiveSession(session); - }, - onSessionTitleChanged: (sessionId: string, title: string) => { - this.remoteSessionController.updateSessionTitle(sessionId, title); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onPollRequested: () => { - this.pollActiveSession(); - } - } - ); - private readonly remoteFileDownloadController: RemoteFileDownloadController = - new RemoteFileDownloadController( - this.sessionManager, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); - }, - () => { - this.remotePageState.clearDownloadingFilePath(); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - private readonly remoteToolActionController: RemoteToolActionController = - new RemoteToolActionController( - this.sessionManager, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - () => { - this.pollActiveSession(); - } - ); - private readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = - new RemoteChatPollingLifecycleController( - this.sessionManager, - { - canPoll: (sessionId: string) => { - return this.activeSession.sessionId === sessionId && - this.isRoute(AppRoute.RemoteChat) && - this.ensureRemoteAvailable(); - }, - onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { - this.applyChatSessionSnapshot(snapshot); - }, - onError: (error: Object) => { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(error)); - } - } - ); - private readonly remoteModelController: RemoteModelController = - new RemoteModelController( - this.sessionManager, - this.identityStore, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.modelCatalog = modelCatalog; - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.modelCatalog = modelCatalog; - this.selectedModelId = selectedModelId; - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - private readonly navigationStack: NavPathStack = new NavPathStack(); - private userIdFailureCount: number = 0; - private userIdLockUntil: number = 0; - - private remoteUrl: string = ''; - private userId: string = ''; - private authenticatedUserId: string = ''; - private statusText: string = RemoteI18n.t('status.waitingConnection'); - private connectionState: ConnectionState = ConnectionState.Idle; - private connectionFailureKind: string = ''; - private isBusy: boolean = false; - private showRemoteUrlInput: boolean = false; - private workspaceName: string = RemoteI18n.t('status.notConnected'); - private workspacePath: string = ''; - private workspaceBranch: string = ''; - private workspaceKind: string = 'normal'; - private assistantId: string = ''; - private desktopName: string = 'MacBook Pro'; - private desktopId: string = ''; - private sessionQuery: string = ''; - private sessionFilter: string = 'all'; - private activeSession: SessionSummary = { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - private messages: ChatMessage[] = []; - private pendingMessages: ChatMessage[] = []; - private hasMoreMessages: boolean = false; - private activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - private timelineItems: ChatTimelineItem[] = []; - private modelCatalog: RemoteModelCatalog = { - version: 0, - models: [], - default_models: {} - }; - private selectedModelId: string = ''; + private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); + private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); async aboutToAppear(): Promise { - this.syncRemotePageSummary(); - await this.generalChatBootstrapController.restore(getContext(this)); - await this.restoreIdentity(); + this.hostAdapter.attach(getContext(this), this.getUIContext()); + await this.runtime.aboutToAppear(); } onPageShow(): void { - RemoteLogger.info(`page show state=${this.connectionState} route=${this.currentRoute()}`); - this.resumeRemoteActivity(); + this.runtime.onPageShow(); } onPageHide(): void { - RemoteLogger.info(`page hide state=${this.connectionState} route=${this.currentRoute()}`); - this.stopPolling(); - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true, 'failed'); - this.stopHeartbeat(); + this.runtime.onPageHide(); } aboutToDisappear(): void { - this.stopPolling(); - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true, 'failed'); - this.generalChatDraftLifecycleController.cancel(); - this.remoteFileDownloadController.cancel(); - this.stopHeartbeat(); - this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { - this.setAllVoiceListening(false); - }); - } - - private currentRoute(): AppRoute { - return AppRouteContract.currentRoute(this.navigationStack.getAllPathName()); - } - - private isGeneralComposerRoute(route: AppRoute): boolean { - return AppRootRouteState.isGeneralComposerRoute(route); - } - - private visibleChatInput(): string { - return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - private visibleSelectedImages(): SelectedImageAttachment[] { - return AppRootRouteState.selectedImages(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - private visibleVoiceListening(): boolean { - return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - private setChatInputForRoute(route: AppRoute, value: string): void { - AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); - } - - private setSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.setSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - private addSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.addSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - private removeSelectedImageForRoute(route: AppRoute, imageId: string): void { - AppRootRouteState.removeSelectedImage(route, imageId, this.generalChatPageState, this.remotePageState); - } - - private clearComposerForRoute(route: AppRoute): void { - AppRootRouteState.clearComposer(route, this.generalChatPageState, this.remotePageState); - } - - private setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { - AppRootRouteState.setVoiceListening( - route, - isVoiceListening, - this.generalChatPageState, - this.remotePageState - ); - } - - private setAllVoiceListening(isVoiceListening: boolean): void { - this.generalChatPageState.setVoiceListening(isVoiceListening); - this.remotePageState.setVoiceListening(isVoiceListening); - } - - private voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { - return AppRootRouteState.snapshot( - route, - this.visibleChatBusy(), - this.generalChatPageState, - this.remotePageState - ); - } - - private isRoute(route: AppRoute): boolean { - return this.currentRoute() === route; - } - - private isGeneralChatVisible(): boolean { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat); - } - - private pushRoute(route: AppRoute, sessionId: string = ''): void { - const spec = AppRouteContract.pathSpec(this.currentRoute(), route, sessionId); - if (!spec) { - return; - } - if (spec.hasSessionParam()) { - this.navigationStack.pushPath({ name: spec.name, param: spec.routeParam() }); - return; - } - this.navigationStack.pushPath({ name: spec.name }); - } - - private replaceRoute(route: AppRoute, sessionId: string = ''): void { - this.navigationStack.clear(); - if (route !== AppRoute.ChatHome) { - this.pushRoute(route, sessionId); - } - } - - private popRoute(fallback: AppRoute): void { - if (this.navigationStack.getAllPathName().length > 0) { - this.navigationStack.pop(); - return; - } - this.replaceRoute(fallback); - } - - private handleNavigationBack(route: AppRoute): boolean { - const action = AppRouteContract.backAction(route, this.appShellState.showSidebar); - if (action === AppNavigationBackAction.CloseSidebar) { - this.closeAppSidebar(); - return true; - } - if (action === AppNavigationBackAction.CloseActiveChat) { - this.closeActiveChat(); - return true; - } - if (action === AppNavigationBackAction.PopRemoteHome) { - this.popRoute(AppRoute.ChatHome); - return true; - } - return false; + this.runtime.aboutToDisappear(); } build() { Stack() { - AppShell({ - shellState: this.appShellState, - content: () => { - this.NavigationContent(); - }, - sidebar: () => { - this.AppSidebarContent(); - }, - settings: () => { - this.SettingsLayer(); - }, - connect: () => { - this.ConnectSheet(); - }, - onCloseSidebar: () => { - this.closeAppSidebar(); - } - }) - } - .width('100%') - .height('100%') - } - - @Builder - NavigationContent() { - Navigation(this.navigationStack) { - this.RouteContent(AppRoute.ChatHome) - } - .width('100%') - .height('100%') - .hideTitleBar(true) - .mode(NavigationMode.Stack) - .navDestination(this.NavigationDestination) - } - - @Builder - NavigationDestination(name: string) { - NavDestination() { - this.RouteContent(name as AppRoute) - } - .hideTitleBar(true) - .backgroundColor(PAGE_BG) - .onBackPressed(() => { - return this.handleNavigationBack(name as AppRoute); - }) - } - - @Builder - RouteContent(route: AppRoute) { - Column() { - if (route === AppRoute.RemoteHome) { - RemoteHomeView({ - pageState: this.remotePageState, - isBusy: this.remotePageState.isBusy, - onOpenSidebar: () => { - this.openAppSidebar(); - }, - onConnectWorkspace: () => { - this.enterCodeEntry(); - }, - onAddConnection: () => { - this.openAddConnection(); - }, - onOpenRemoteSettings: () => { - this.openRemoteControlSettings(); - }, - onRefresh: () => { - this.refreshSessions(); - }, - onShowWorkspaces: () => { - this.showRecentWorkspaces(); - }, - onShowAssistants: () => { - this.showAssistants(); - }, - onSelectWorkspace: (path: string) => { - this.selectWorkspace(path); - }, - onSelectAssistant: (path: string) => { - this.selectAssistant(path); - }, - onCancelWorkspacePicker: () => { - this.remotePageState.setWorkspacePickerVisible(false); - }, - onCancelAssistantPicker: () => { - this.remotePageState.setAssistantPickerVisible(false); - }, - onSessionQueryChange: (value: string) => { - this.sessionQuery = value; - this.remotePageState.setQuery(value); - }, - onSearchSessions: () => { - this.refreshSessions(); - }, - onLoadMoreSessions: () => { - this.loadMoreSessions(); - }, - onReconnect: () => { - this.reconnect(); - }, - onDisconnect: () => { - this.disconnect(false); - }, - onClearPairing: () => { - this.disconnect(true); - }, - onCreate: (agentType: string) => { - this.createSession(agentType); - }, - onCreateAssistantSession: () => { - this.createSession('Claw'); - }, - onCreateInWorkspace: (path: string) => { - this.createSessionInWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.openHomeSession(session); - }, - onDeleteSession: (session: RemoteSession) => { - this.deleteHomeSession(session); - } - }) - } else { - ConversationView({ - activeSession: route === AppRoute.RemoteChat ? - this.remotePageState.activeSession : this.generalChatPageState.activeSession, - surface: route === AppRoute.RemoteChat ? ChatSurface.Remote : ChatSurface.General, - desktopName: route === AppRoute.RemoteChat ? this.remotePageState.desktopName : '', - workspaceBranch: route === AppRoute.RemoteChat ? this.remotePageState.workspaceBranch : '', - statusText: route === AppRoute.RemoteChat ? - this.remotePageState.statusText : this.generalChatPageState.statusText, - inlineStatusText: route === AppRoute.RemoteChat ? '' : this.generalChatHomeStatusText(), - connectionState: route === AppRoute.RemoteChat ? - this.remotePageState.connectionState : - GeneralChatServiceStatus.connectionState(this.generalChatPageState.serviceState), - composerCapabilities: route === AppRoute.RemoteChat ? - REMOTE_CHAT_COMPOSER_CAPABILITIES : GENERAL_CHAT_COMPOSER_CAPABILITIES, - isBusy: this.visibleChatBusy(), - canStop: route === AppRoute.RemoteChat ? - this.remotePageState.hasRunningActiveTurn() : this.generalChatPageState.hasRunningActiveTurn(), - hasMoreMessages: route === AppRoute.RemoteChat ? - this.remotePageState.hasMoreMessages : this.generalChatPageState.hasMoreMessages, - timelineItems: route === AppRoute.RemoteChat ? - this.remotePageState.timelineItems : this.generalChatPageState.timelineItems, - timelineRevision: route === AppRoute.RemoteChat ? - this.remotePageState.timelineRevision : this.generalChatPageState.timelineRevision, - showSuggestionsWhenEmpty: route !== AppRoute.RemoteChat, - supportsSearch: false, - supportsImages: false, - supportsFiles: false, - modelCatalog: route === AppRoute.RemoteChat ? - this.remotePageState.modelCatalog : RemoteUiState.emptyModelCatalog(), - selectedModelId: route === AppRoute.RemoteChat ? this.remotePageState.selectedModelId : '', - isSessionPinned: AppRouteContract.isGeneralComposerRoute(route) && - this.generalChatPageState.activeSession.sessionId.length > 0 && - this.generalChatPageState.pinnedSessionId() === this.generalChatPageState.activeSession.sessionId, - downloadingFilePath: route === AppRoute.RemoteChat ? this.remotePageState.downloadingFilePath : '', - downloadedFilePath: route === AppRoute.RemoteChat ? this.remotePageState.downloadedFilePath : '', - fileDownloadStatus: route === AppRoute.RemoteChat ? this.remotePageState.fileDownloadStatus : '', - selectedImages: route === AppRoute.RemoteChat ? - this.remotePageState.selectedImages : this.generalChatPageState.selectedImages, - isVoiceListening: route === AppRoute.RemoteChat ? - this.remotePageState.isVoiceListening : this.generalChatPageState.isVoiceListening, - chatInput: route === AppRoute.RemoteChat ? - this.remotePageState.chatInput : this.generalChatPageState.chatInput, - onOpenSidebar: () => { - this.openAppSidebar(); - }, - onBack: () => { - this.closeActiveChat(); - }, - onNewSession: () => { - if (route === AppRoute.RemoteChat) { - this.createSession('code'); - } else { - this.prepareNewGeneralChat(); - } - }, - onTogglePinSession: () => { - if (AppRouteContract.isGeneralComposerRoute(route) && - this.generalChatPageState.activeSession.sessionId.length > 0) { - const session = this.activeGeneralChatAsRemoteSession(); - this.generalChatCommandController.pinSession( - session, - this.generalChatPageState.pinnedSessionId() !== session.id, - this.generalChatPageState.isBusy - ); - } - }, - onArchiveSession: () => { - if (AppRouteContract.isGeneralComposerRoute(route) && - this.generalChatPageState.activeSession.sessionId.length > 0) { - this.archiveHomeSession(this.activeGeneralChatAsRemoteSession(), true); - } - }, - onDeleteSession: () => { - if (AppRouteContract.isGeneralComposerRoute(route) && - this.generalChatPageState.activeSession.sessionId.length > 0) { - const session = this.activeGeneralChatAsRemoteSession(); - this.deleteHomeSession(session).then(() => { - this.prepareNewGeneralChat(); - }); - } - }, - onShowUploadedFiles: () => { - const count = this.activeGeneralUploadedFileCount(); - this.showHomeToast(count > 0 ? `当前会话已上传 ${count} 个文件` : '当前会话暂无已上传文件'); - }, - onStop: () => { - this.stopActiveChatTask(); - }, - onLoadOlder: () => { - this.loadOlderMessages(); - }, - onApproveTool: (toolId: string, updatedInput?: Object) => { - this.approveTool(toolId, updatedInput); - }, - onRejectTool: (toolId: string) => { - this.rejectTool(toolId); - }, - onCancelTool: (toolId: string) => { - this.cancelTool(toolId); - }, - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => { - this.answerQuestion(toolId, answers); - }, - onRenameSession: (title: string) => { - this.renameVisibleSession(title); - }, - onCopyMessage: (text: string) => { - this.copyMessage(text); - }, - onRetryMessage: (text: string) => { - this.retryVisibleMessage(text); - }, - onSelectModel: (modelId: string) => { - this.selectModel(modelId); - }, - onPickImages: () => { - this.pickImages(); - }, - onRemoveImage: (imageId: string) => { - this.removeSelectedImage(imageId); - }, - onDownloadFile: (path: string) => { - this.downloadVisibleFile(path); - }, - onSend: () => { - this.sendVisibleChatMessage(); - }, - onVoiceInput: () => { - this.toggleVoiceInput(); - }, - onChatInputChange: (value: string) => { - this.onVisibleChatInputChange(route, value); - } - }) - } - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - AppSidebarContent() { - AppSidebar({ - sessions: this.generalChatPageState.recentSessions(), - pinnedSessionId: this.generalChatPageState.pinnedSessionId(), - selectedSessionId: this.isGeneralChatVisible() ? - this.generalChatPageState.activeSession.sessionId : '', - connectionState: this.remotePageState.connectionState, - activeSection: this.isRoute(AppRoute.RemoteHome) || this.isRoute(AppRoute.RemoteChat) ? 'remote' : 'chat', - onClose: () => { - this.closeAppSidebar(); - }, - onNewChat: () => { - this.closeAppSidebar(); - this.prepareNewGeneralChat(); - }, - onEnterCode: () => { - this.closeAppSidebar(); - this.enterCodeEntry(); - }, - onOpenSettings: () => { - this.closeAppSidebar(); - this.appShellState.openSettings('general'); - }, - onOpenSession: (session: RemoteSession) => { - this.closeAppSidebar(); - this.openHomeSession(session); - }, - onArchiveSession: (session: RemoteSession, archived: boolean) => { - this.archiveHomeSession(session, archived); - }, - onExportSession: (session: RemoteSession) => { - this.exportHomeSession(session); - }, - onDeleteSession: (session: RemoteSession) => { - this.deleteHomeSession(session); - } - }) - } - - @Builder - SettingsLayer() { - if (this.appShellState.settingsMode === 'remote') { - RemoteControlSettingsSheet({ - desktopName: this.remotePageState.desktopName, - desktopId: this.remotePageState.desktopId, - userId: this.remotePageState.userId, - accountUsername: this.remotePageState.accountUsername, - authenticatedUserId: this.remotePageState.authenticatedUserId, - deviceId: this.deviceId, - connectionState: this.remotePageState.connectionState, - statusText: this.remotePageState.statusText, - isBusy: this.remotePageState.isBusy, - onClose: () => { - this.appShellState.setSettingsVisible(false); - }, - onAddConnection: () => { - this.openAddConnectionFromSettings(); - }, - onDisconnect: () => { - this.disconnect(false); - }, - onReconnect: () => { - this.reconnect(); - } - }) - } else { - SettingsSheet({ - generalChatApiUrl: this.generalChatPageState.apiUrl, - generalChatModelName: this.generalChatPageState.modelName, - hasGeneralChatApiKey: this.generalChatPageState.hasApiKey, - onSaveGeneralChatConfig: ( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ) => { - return this.saveGeneralChatConfig(apiUrl, apiKey, modelName, clearApiKey); - }, - onClose: () => { - this.appShellState.setSettingsVisible(false); - } + AppRootPresentation({ + shellState: this.runtime.appShellState, + navigationStack: this.runtime.navigationStack, + remotePageState: this.runtime.remotePageState, + remoteCreateState: this.runtime.remoteCreateState, + generalPageState: this.runtime.generalChatPageState, + deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), + currentRoute: this.runtime.currentRoute(), + actions: this.runtime.presentationActions }) } - } - - @Builder - ConnectSheet() { - ConnectView({ - remoteUrl: this.remotePageState.remoteUrl, - userId: this.remotePageState.userId, - showRemoteUrlInput: this.remotePageState.showRemoteUrlInput, - statusText: this.remotePageState.statusText, - connectionState: this.remotePageState.connectionState, - connectionFailureKind: this.remotePageState.connectionFailureKind, - isBusy: this.remotePageState.isBusy, - isConnected: this.remotePageState.connectionState === ConnectionState.Connected, - desktopName: this.remotePageState.desktopName, - desktopId: this.remotePageState.desktopId, - deviceId: this.deviceId, - requiresAccountAuth: this.remotePageState.requiresAccountAuth, - accountUsername: this.remotePageState.accountUsername, - onBack: () => { - this.appShellState.setConnectSheetVisible(false); - }, - onConnect: (password?: string) => { - this.connect(false, password || ''); - }, - onClearPairing: () => { - this.appShellState.setConnectSheetVisible(false); - this.disconnect(true); - }, - onRemoteUrlChange: (value: string) => { - this.setRemoteUrl(value); - this.applyRemotePairingProjection(value); - }, - onUserIdChange: (value: string) => { - this.setRemoteUserId(value); - }, - onRemoteUrlDetected: (value: string): boolean => { - return this.handleDetectedRemoteUrl(value); - }, - onRemoteUrlInputVisibleChange: (visible: boolean) => { - this.setRemoteUrlInputVisible(visible); - }, - onPasteRemoteUrl: () => { - this.pasteRemoteUrl(); - }, - onScanRemoteUrl: () => { - this.scanRemoteUrl(); - } - }) .width('100%') .height('100%') } - - private async saveGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const snapshot = await this.generalChatConfigStore.save(update); - this.applyGeneralChatConfig(snapshot); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private applyGeneralChatConfig(snapshot: GeneralChatConfigSnapshot): void { - this.generalChatPageState.setConfiguration( - snapshot.apiUrl, - snapshot.modelName, - snapshot.hasApiKey, - GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) - ); - } - - private async restoreIdentity(): Promise { - try { - const snapshot: MobileIdentitySnapshot = await this.identityStore.init(getContext(this)); - this.deviceId = snapshot.installId; - this.setRemoteUserId(snapshot.userId || snapshot.installId); - this.setRemoteUrl(snapshot.remoteUrl); - this.applyRemotePairingProjection(snapshot.remoteUrl); - this.selectedModelId = await this.identityStore.getLastModelId(); - this.remoteModelController.setPreferredModelId(this.selectedModelId); - this.userIdFailureCount = snapshot.userIdFailureCount; - this.userIdLockUntil = snapshot.userIdLockUntil > Date.now() ? snapshot.userIdLockUntil : 0; - if (snapshot.userIdLockUntil > 0 && this.userIdLockUntil === 0) { - this.userIdFailureCount = 0; - await this.identityStore.clearUserIdProtection(); - } - this.setRemoteUrlInputVisible(snapshot.remoteUrl.length > 0); - if (this.shouldAutoReconnect()) { - this.autoReconnectAttempted = true; - this.setRemoteConnectionState(ConnectionState.Reconnecting); - this.setRemoteStatusText(RemoteI18n.t('status.restoringConnection')); - this.connect(true); - } - } catch (err) { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.setRemoteConnectionState(ConnectionState.Failed); - } - } - - private shouldAutoReconnect(): boolean { - return this.remotePairingPolicy.shouldAutoReconnect({ - autoReconnectAttempted: this.autoReconnectAttempted, - remoteUrl: this.remoteUrl, - userId: this.userId, - requiresAccountAuth: this.remotePageState.requiresAccountAuth - }); - } - - private async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - if (this.isBusy) { - return; - } - try { - this.setRemoteBusy(true); - this.setRemoteConnectionFailureKind(''); - if (this.userIdLockUntil > 0 && !ConnectionErrorPolicy.isLocked(this.userIdLockUntil)) { - this.userIdFailureCount = 0; - this.userIdLockUntil = 0; - await this.identityStore.clearUserIdProtection(); - } - if (!autoReconnect && ConnectionErrorPolicy.isLocked(this.userIdLockUntil)) { - throw new Error(RemoteI18n.f('errors.tooManyAttempts', `${ConnectionErrorPolicy.remainingLockSeconds(this.userIdLockUntil)}`)); - } - this.setRemoteConnectionState(this.connectionState === ConnectionState.Reconnecting ? - ConnectionState.Reconnecting : ConnectionState.Parsing); - this.setRemoteStatusText(RemoteI18n.t('status.parsingUrl')); - const descriptor = RemoteDescriptorParser.parse(this.remoteUrl); - this.applyRemotePairingProjection(this.remoteUrl); - const identity = this.remotePairingPolicy.identityForConnect( - descriptor, - this.userId, - this.deviceId, - accountPassword, - autoReconnect - ); - this.setRemoteUserId(identity.userId); - - this.setRemoteConnectionState(ConnectionState.Pairing); - this.setRemoteStatusText(RemoteI18n.t('status.pairing')); - const initialSync: InitialSyncResult = await this.sessionManager.connect( - descriptor, - identity, - this.deviceId - ); - await this.identityStore.savePairingInput(identity.userId, this.remoteUrl); - await this.identityStore.clearUserIdProtection(); - this.userIdFailureCount = 0; - this.userIdLockUntil = 0; - this.applyWorkspace(initialSync.workspace); - this.setRemoteAuthenticatedUserId(initialSync.authenticatedUserId); - this.remoteSessionController.setSessions(initialSync.sessions, initialSync.hasMoreSessions); - this.setRemoteConnectionState(ConnectionState.Connected); - this.setRemoteConnectionFailureKind(''); - this.setRemoteStatusText(RemoteI18n.t('connection.connected')); - this.appShellState.setConnectSheetVisible(false); - if (!autoReconnect) { - this.pushRoute(AppRoute.RemoteHome); - } - this.startHeartbeat(); - this.loadRecentWorkspacesInBackground(); - } catch (err) { - const result: ConnectionErrorResult = await ConnectionErrorPolicy.connectionErrorResult(err, autoReconnect, { - failureCount: this.userIdFailureCount, - lockUntil: this.userIdLockUntil - }, this.identityStore); - this.setRemoteStatusText(result.message); - this.setRemoteConnectionFailureKind(result.failureKind); - this.userIdFailureCount = result.failureCount; - this.userIdLockUntil = result.lockUntil; - if (result.shouldShowRemoteUrlInput) { - this.setRemoteUrlInputVisible(true); - } - this.setRemoteConnectionState(ConnectionState.Failed); - } finally { - this.setRemoteBusy(false); - } - } - - private async reconnect(): Promise { - if (this.isBusy) { - return; - } - this.setRemoteConnectionState(ConnectionState.Reconnecting); - this.setRemoteStatusText(RemoteI18n.t('status.reconnecting')); - await this.connect(); - } - - private async disconnect(clearPairing: boolean): Promise { - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remoteSessionController.clearSessions(); - this.remotePageState.clear(); - this.resetChatTimeline(''); - this.hasMoreMessages = false; - this.remoteModelController.clearCatalog(); - this.remotePageState.clearActiveSession(); - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - this.remoteFileDownloadController.clear(); - this.activeSession = { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - this.workspaceName = RemoteI18n.t('status.notConnected'); - this.workspacePath = ''; - this.workspaceBranch = ''; - this.workspaceKind = 'normal'; - this.assistantId = ''; - this.remotePageState.setWorkspace( - this.workspaceName, - this.workspacePath, - this.assistantId, - this.workspaceBranch, - this.workspaceKind - ); - this.setRemoteAuthenticatedUserId(''); - this.remotePageState.clearWorkspaceActions(); - this.setRemoteConnectionState(ConnectionState.Disconnected); - this.setRemoteConnectionFailureKind(''); - this.setRemoteStatusText(clearPairing ? RemoteI18n.t('status.pairingCleared') : RemoteI18n.t('status.disconnected')); - this.replaceRoute(AppRoute.RemoteHome); - if (clearPairing) { - this.setRemoteUrl(''); - this.setRemoteUserId(this.deviceId); - this.remotePageState.setAccountPairing(false, ''); - this.setRemoteUrlInputVisible(false); - await this.identityStore.clearPairingInput(); - } else { - this.setRemoteUrlInputVisible(this.remoteUrl.trim().length > 0); - } - } - - private async pasteRemoteUrl(): Promise { - try { - this.setRemoteStatusText(RemoteI18n.t('status.readClipboard')); - const text = (await this.clipboardService.readText()).trim(); - if (text.length === 0) { - this.setRemoteUrlInputVisible(true); - this.setRemoteStatusText(RemoteI18n.t('status.clipboardEmpty')); - return; - } - this.setRemoteUrl(text); - this.applyRemotePairingProjection(text); - this.setRemoteConnectionFailureKind(''); - this.setRemoteUrlInputVisible(true); - try { - RemoteDescriptorParser.parse(text); - this.setRemoteStatusText(RemoteI18n.t('status.clipboardUrlFilled')); - } catch (_err) { - this.setRemoteStatusText(RemoteI18n.t('status.clipboardNeedsConfirm')); - } - } catch (err) { - this.setRemoteUrlInputVisible(true); - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - private async scanRemoteUrl(): Promise { - try { - this.setRemoteStatusText(RemoteI18n.t('status.openScanner')); - const text = (await this.qrScanService.scanRemoteUrl(getContext(this))).trim(); - if (text.length === 0) { - this.setRemoteStatusText(RemoteI18n.t('status.scanEmpty')); - return; - } - this.handleDetectedRemoteUrl(text); - } catch (err) { - this.setRemoteUrlInputVisible(true); - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - private handleDetectedRemoteUrl(remoteUrl: string): boolean { - try { - this.setRemoteUrl(remoteUrl); - this.applyRemotePairingProjection(remoteUrl); - this.setRemoteConnectionFailureKind(''); - this.setRemoteUrlInputVisible(true); - const descriptor = RemoteDescriptorParser.parse(remoteUrl); - this.setRemoteStatusText(RemoteI18n.t('status.scannedUrl')); - if (this.remotePairingPolicy.shouldPromptForAccount(descriptor)) { - this.setRemoteStatusText(RemoteI18n.t('connect.enterAccountToPair')); - this.appShellState.setConnectSheetVisible(true); - return true; - } - this.connect(); - return false; - } catch (err) { - this.setRemoteUrlInputVisible(true); - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - return true; - } - } - - private applyWorkspace(workspace: WorkspaceInfo): void { - this.workspaceName = workspace.name; - this.workspacePath = workspace.path; - this.workspaceBranch = workspace.gitBranch; - this.workspaceKind = workspace.workspaceKind || 'normal'; - this.assistantId = workspace.assistantId || ''; - this.remotePageState.setWorkspace( - this.workspaceName, - this.workspacePath, - this.assistantId, - this.workspaceBranch, - this.workspaceKind - ); - } - - private syncRemoteDesktopIdentity(remoteUrl: string): void { - if (remoteUrl.trim().length === 0) { - this.desktopId = ''; - this.remotePageState.setDesktopIdentity(this.desktopName, this.desktopId); - return; - } - this.desktopName = RemoteUiState.desktopNameFromRemoteUrl(remoteUrl); - this.desktopId = RemoteUiState.desktopIdFromRemoteUrl(remoteUrl); - this.remotePageState.setDesktopIdentity(this.desktopName, this.desktopId); - } - - private applyRemotePairingProjection(remoteUrl: string): void { - this.syncRemoteDesktopIdentity(remoteUrl); - const projection: RemotePairingProjection = this.remotePairingPolicy.projection(remoteUrl); - this.remotePageState.setAccountPairing(projection.requiresAccountAuth, projection.accountUsername); - const projectedUserId = this.remotePairingPolicy.userIdAfterProjection(this.userId, this.deviceId, projection); - if (projectedUserId !== this.userId) { - this.setRemoteUserId(projectedUserId); - } - } - - private setRemoteConnectionState(connectionState: ConnectionState): void { - this.connectionState = connectionState; - this.remotePageState.setConnectionState(connectionState); - } - - private setRemoteUrl(remoteUrl: string): void { - this.remoteUrl = remoteUrl; - this.remotePageState.setRemoteUrl(remoteUrl); - } - - private setRemoteUserId(userId: string): void { - this.userId = userId; - this.remotePageState.setUserId(userId); - } - - private setRemoteAuthenticatedUserId(authenticatedUserId: string): void { - this.authenticatedUserId = authenticatedUserId; - this.remotePageState.setAuthenticatedUserId(authenticatedUserId); - } - - private setRemoteStatusText(statusText: string): void { - this.statusText = statusText; - this.remotePageState.setStatusText(statusText); - } - - private setRemoteConnectionFailureKind(connectionFailureKind: string): void { - this.connectionFailureKind = connectionFailureKind; - this.remotePageState.setConnectionFailureKind(connectionFailureKind); - } - - private setRemoteBusy(isBusy: boolean): void { - this.isBusy = isBusy; - this.remotePageState.setBusy(isBusy); - } - - private setRemoteUrlInputVisible(visible: boolean): void { - this.showRemoteUrlInput = visible; - this.remotePageState.setRemoteUrlInputVisible(visible); - } - - private syncRemotePageSummary(): void { - this.remotePageState.setDesktopIdentity(this.desktopName, this.desktopId); - this.remotePageState.setPairingInput(this.remoteUrl, this.userId, this.showRemoteUrlInput); - this.remotePageState.setAuthenticatedUserId(this.authenticatedUserId); - this.remotePageState.setStatusText(this.statusText); - this.remotePageState.setConnectionState(this.connectionState); - this.remotePageState.setConnectionFailureKind(this.connectionFailureKind); - this.remotePageState.setBusy(this.isBusy); - this.remotePageState.setWorkspace( - this.workspaceName, - this.workspacePath, - this.assistantId, - this.workspaceBranch, - this.workspaceKind - ); - } - - private ensureRemoteAvailable(): boolean { - if (RemoteUiState.canUseRemote(this.connectionState)) { - return true; - } - this.setRemoteStatusText(RemoteI18n.t('status.remoteUnavailable')); - return false; - } - - private failRemoteConnection(err: Object): void { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.setRemoteConnectionState(ConnectionState.Failed); - this.stopHeartbeat(); - } - - private async showRecentWorkspaces(): Promise { - if (this.remotePageState.showWorkspacePicker) { - this.remotePageState.setWorkspacePickerVisible(false); - return; - } - this.remotePageState.setWorkspacePickerVisible(true); - this.remotePageState.setAssistantPickerVisible(false); - await this.loadRecentWorkspaces(); - } - - private async showAssistants(): Promise { - if (this.remotePageState.showAssistantPicker) { - this.remotePageState.setAssistantPickerVisible(false); - return; - } - this.remotePageState.setAssistantPickerVisible(true); - this.remotePageState.setWorkspacePickerVisible(false); - await this.loadAssistants(); - } - - private async loadRecentWorkspaces(): Promise { - if (!this.ensureRemoteAvailable()) { - return; - } - try { - this.setRemoteBusy(true); - this.setRemoteStatusText(RemoteI18n.t('status.loadingRecentWorkspaces')); - const recentWorkspaces = await this.sessionManager.listRecentWorkspaces(); - this.remotePageState.setRecentWorkspaces(recentWorkspaces); - this.setRemoteStatusText(recentWorkspaces.length > 0 ? RemoteI18n.t('status.chooseWorkspace') : RemoteI18n.t('status.noRecentWorkspaces')); - } catch (err) { - this.failRemoteConnection(err); - } finally { - this.setRemoteBusy(false); - } - } - - private async selectWorkspace(path: string): Promise { - if (this.isBusy || path.trim().length === 0) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - try { - this.setRemoteBusy(true); - this.setRemoteStatusText(RemoteI18n.t('status.switchingWorkspace')); - await this.sessionManager.setWorkspace(path); - const workspace = await this.sessionManager.getWorkspaceInfo(); - this.applyWorkspace(workspace); - this.remotePageState.closePickers(); - this.remoteSessionController.clearSessions(); - this.remotePageState.clear(); - this.setRemoteStatusText(RemoteI18n.t('status.workspaceSwitched')); - this.setRemoteBusy(false); - await this.refreshSessions(); - } catch (err) { - this.failRemoteConnection(err); - this.setRemoteBusy(false); - } - } - - private async loadAssistants(): Promise { - if (!this.ensureRemoteAvailable()) { - return; - } - try { - this.setRemoteBusy(true); - this.setRemoteStatusText(RemoteI18n.t('status.loadingAssistants')); - const assistants = await this.sessionManager.listAssistants(); - this.remotePageState.setAssistants(assistants); - this.setRemoteStatusText(assistants.length > 0 ? RemoteI18n.t('status.chooseAssistant') : RemoteI18n.t('status.noAssistants')); - } catch (err) { - this.failRemoteConnection(err); - } finally { - this.setRemoteBusy(false); - } - } - - private async selectAssistant(path: string): Promise { - if (this.isBusy || path.trim().length === 0) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - try { - this.setRemoteBusy(true); - this.setRemoteStatusText(RemoteI18n.t('status.switchingAssistant')); - await this.sessionManager.setAssistant(path); - const workspace = await this.sessionManager.getWorkspaceInfo(); - this.applyWorkspace(workspace); - this.remotePageState.closePickers(); - this.remoteSessionController.clearSessions(); - this.remotePageState.clear(); - this.setRemoteStatusText(RemoteI18n.t('status.assistantSwitched')); - this.setRemoteBusy(false); - await this.refreshSessions(); - } catch (err) { - this.failRemoteConnection(err); - this.setRemoteBusy(false); - } - } - - private async refreshSessions(): Promise { - await this.remoteSessionController.refresh( - this.sessionQuery, - this.sessionFilter, - this.ensureRemoteAvailable(), - this.connectionState === ConnectionState.Connected - ); - } - - private async loadMoreSessions(): Promise { - await this.remoteSessionController.loadMore( - this.sessionQuery, - this.sessionFilter, - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - private setSessionFilter(filter: string): void { - if (this.sessionFilter === filter) { - return; - } - this.sessionFilter = filter; - this.refreshSessions(); - } - - private visibleChatBusy(): boolean { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.isBusy : this.remotePageState.isBusy; - } - - private visibleStatusText(): string { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.statusText : this.remotePageState.statusText; - } - - private setVisibleStatusText(statusText: string): void { - if (this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat)) { - this.generalChatPageState.setStatus(statusText); - return; - } - this.setRemoteStatusText(statusText); - } - - private openAppSidebar(): void { - this.getUIContext().animateTo({ duration: 230, curve: Curve.EaseOut }, () => { - this.appShellState.setSidebarVisible(true); - }); - } - - private closeAppSidebar(): void { - this.getUIContext().animateTo({ duration: 210, curve: Curve.EaseOut }, () => { - this.appShellState.setSidebarVisible(false); - }); - } - - private enterCodeEntry(): void { - if (RemoteUiState.canUseRemote(this.connectionState)) { - this.appShellState.setConnectSheetVisible(false); - this.pushRoute(AppRoute.RemoteHome); - return; - } - this.appShellState.setConnectSheetVisible(true); - } - - private openAddConnection(): void { - this.appShellState.setConnectSheetVisible(true); - } - - private openRemoteControlSettings(): void { - setTimeout(() => { - this.appShellState.openSettings('remote'); - }, 180); - } - - private openAddConnectionFromSettings(): void { - this.appShellState.setSettingsVisible(false); - setTimeout(() => { - this.openAddConnection(); - }, 220); - } - - private openHomeSession(session: RemoteSession): void { - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session); - } - - private async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.deleteSession(session); - return; - } - await this.generalChatCommandController.deleteSession(session, this.generalChatPageState.isBusy); - } - - private activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.generalChatPageState.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.generalChatPageState.timelineItems.length, - workspacePath: active.workspacePath - }; - } - - private activeGeneralUploadedFileCount(): number { - let count = 0; - this.generalChatPageState.timelineItems.forEach((item: ChatTimelineItem) => { - if (item.message && item.message.images) { - count += item.message.images.length; - } - }); - return count; - } - - private async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await this.generalChatCommandController.archiveSession(session, archived, this.generalChatPageState.isBusy); - } - - private async exportHomeSession(session: RemoteSession): Promise { - await this.generalChatCommandController.exportSession( - session, - this.generalChatPageState.isBusy, - async (text: string): Promise => { - await this.clipboardService.writeText(text); - } - ); - } - - private async openGeneralSession(item: RemoteSession): Promise { - if (this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - await this.generalChatCommandController.openSession( - item, - this.generalChatPageState.isBusy, - async (sessionId: string): Promise => { - return this.generalChatDraftLifecycleController.restore(sessionId); - }, - (sessionId: string) => { - this.replaceRoute(AppRoute.ChatHome, sessionId); - } - ); - } - - private async startGeneralChat(text: string): Promise { - const trimmed = text.trim(); - if (trimmed.length === 0 || this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - this.generalChatDraftLifecycleController.cancel(); - const created = await this.generalChatCommandController.createSession( - trimmed, - this.generalChatPageState.isBusy, - async (): Promise => { - await this.generalChatDraftLifecycleController.clearHomeNow(); - }, - (sessionId: string) => { - this.replaceRoute(AppRoute.ChatHome, sessionId); - } - ); - if (!created) { - return; - } - await this.sendGeneralChatMessage(); - } - - private async sendVisibleChatMessage(): Promise { - if (this.isGeneralChatVisible()) { - if ((this.generalChatPageState.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await this.sendGeneralChatMessage(); - return; - } - await this.sendChatMessage(); - } - - private async stopActiveChatTask(): Promise { - if (this.isGeneralChatVisible()) { - this.stopGeneralChatStream(true); - return; - } - await this.stopActiveTask(); - } - - private closeActiveChat(): void { - this.stopVoiceInput(false); - if (this.isRoute(AppRoute.GeneralChat)) { - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true); - this.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } - this.stopPolling(); - this.popRoute(AppRoute.RemoteHome); - } - - private async renameVisibleSession(title: string): Promise { - if (this.isGeneralChatVisible()) { - await this.generalChatCommandController.renameActiveSession( - this.generalChatPageState.activeSession, - title - ); - return; - } - await this.renameActiveSession(title); - } - - private async retryVisibleMessage(text: string): Promise { - if (this.isGeneralChatVisible()) { - const sessionId = this.generalChatPageState.activeSession.sessionId || ''; - const prepared = await this.generalChatCommandController.retryMessage( - sessionId, - text, - this.generalChatPageState.isBusy - ); - if (prepared) { - await this.sendGeneralChatMessage(); - } - return; - } - this.retryMessage(text); - } - - private downloadVisibleFile(path: string): void { - if (this.isGeneralChatVisible()) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.downloadFile(path); - } - - private async createSession(agentType: string): Promise { - await this.remoteSessionController.create( - agentType, - this.isBusy, - this.ensureRemoteAvailable(), - async (session: SessionSummary): Promise => { - this.remotePageState.clearComposer(); - this.hasMoreMessages = false; - this.resetChatTimeline(session.sessionId); - this.knownPollVersion = 0; - this.knownRemoteMessageCount = 0; - this.pushRoute(AppRoute.RemoteChat, session.sessionId); - await this.loadModelCatalog(session.sessionId); - await this.refreshSessions(); - await this.loadActiveMessages(); - this.startPolling(); - } - ); - } - - private async createSessionInWorkspace(path: string): Promise { - if (path.length > 0 && path !== this.workspacePath) { - await this.selectWorkspace(path); - } - if (path.length === 0 || path === this.workspacePath) { - await this.createSession('code'); - } - } - - private async loadWorkspaceSessions(paths: string[]): Promise { - const all: RemoteSession[] = []; - for (const path of paths) { - try { - const sessions = await this.sessionManager.listSessionsForWorkspace(path, 50); - sessions.forEach((item: RemoteSession) => { - if (!all.some((existing: RemoteSession) => existing.id === item.id)) { - all.push(item); - } - }); - } catch (err) { - RemoteLogger.warn(`load workspace sessions failed path=${path}: ${String(err)}`); - } - } - this.remoteWorkspaceSessions = all; - const current = this.remotePageState.sessions; - const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.workspacePath); - this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); - } - - private async loadRecentWorkspacesInBackground(): Promise { - try { - const recentWorkspaces = await this.sessionManager.listRecentWorkspaces(); - this.remotePageState.setRecentWorkspaces(recentWorkspaces); - await this.loadWorkspaceSessions(recentWorkspaces.map((item: RecentWorkspaceEntry) => item.path)); - } catch (err) { - RemoteLogger.warn(`background recent workspace load failed: ${String(err)}`); - } - } - - private mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { - const merged = primary.slice(); - extras.forEach((item: RemoteSession) => { - if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { - merged.push(item); - } - }); - return merged; - } - - private async openSession(item: RemoteSession): Promise { - await this.remoteSessionController.open( - item, - item.workspacePath || this.workspacePath, - this.isBusy, - this.ensureRemoteAvailable(), - async (session: SessionSummary): Promise => { - this.stopPolling(); - this.resetChatTimeline(item.id); - this.knownPollVersion = 0; - this.knownRemoteMessageCount = 0; - this.hasMoreMessages = false; - this.remoteFileDownloadController.clear(); - this.remotePageState.clearComposer(); - this.pushRoute(AppRoute.RemoteChat, item.id); - await this.loadModelCatalog(item.id); - await this.loadActiveMessages(); - if (this.activeSession.sessionId === session.sessionId && this.isRoute(AppRoute.RemoteChat)) { - this.startPolling(); - } - } - ); - } - - private async deleteSession(item: RemoteSession): Promise { - await this.remoteSessionController.delete( - item, - this.activeSession.sessionId, - this.isBusy, - this.ensureRemoteAvailable(), - this.sessionQuery, - this.sessionFilter, - async (): Promise => { - this.stopPolling(); - this.resetChatTimeline(''); - this.hasMoreMessages = false; - this.remotePageState.clearComposer(); - this.remoteModelController.clearCatalog(); - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - this.activeSession = { - sessionId: '', - title: '', - workspacePath: this.workspacePath, - agentType: 'code' - }; - this.remotePageState.clearActiveSession(); - this.replaceRoute(AppRoute.RemoteHome); - } - ); - } - - private async loadActiveMessages(): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteChatCommandController.loadMessages( - sessionId, - (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); - } - ); - } - - private async loadModelCatalog(sessionId: string): Promise { - await this.remoteModelController.loadCatalog( - sessionId, - this.ensureRemoteAvailable(), - (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); - } - ); - } - - private async selectModel(modelId: string): Promise { - await this.remoteModelController.selectModel( - modelId, - this.activeSession.sessionId || '', - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - private async loadOlderMessages(): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteChatCommandController.loadOlderMessages( - sessionId, - this.knownPollVersion, - this.hasMoreMessages, - this.isBusy - ); - } - - private async sendGeneralChatMessage(): Promise { - if (this.generalChatPageState.isVoiceListening) { - await this.stopVoiceInput(false); - } - const rawText = this.generalChatPageState.chatInput.trim(); - const images = this.generalChatPageState.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.generalChatPageState.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.generalChatPageState.isBusy) { - return; - } - this.generalChatPageState.clearComposer(); - this.generalChatDraftLifecycleController.clear(sessionId); - const localMessage = RemoteUiState.localUserMessage(text, images); - this.chatTimelineStore.appendOptimisticMessage(localMessage); - this.syncGeneralChatTimelineFromStore(); - try { - this.generalChatPageState.setBusy(true); - this.generalChatPageState.setServiceState(GeneralChatServiceState.Sending); - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.generating')); - this.beginGeneralChatNetworkStream(sessionId); - const observer = new GeneralChatStreamCallbacks( - async (message: ChatMessage): Promise => { - this.chatTimelineStore.mergePersistedMessages([message]); - this.syncGeneralChatTimelineFromStore(); - }, - (delta: string): void => { - this.appendGeneralChatNetworkDelta(sessionId, delta); - } - ); - this.generalChatStreamLifecycleController.setRequestInFlight(true); - const result: GeneralChatSendResult = await this.generalChatCommandController.sendMessage( - sessionId, - text, - images, - observer - ); - this.generalChatStreamLifecycleController.setRequestInFlight(false); - this.flushGeneralChatNetworkDelta(sessionId); - this.generalChatCommandController.refreshSessions(); - if (this.generalChatStreamLifecycleController.text().length > 0) { - this.finishGeneralChatNetworkStream(sessionId, result); - } else { - this.resetGeneralChatNetworkStream(); - this.chatTimelineStore.mergePersistedMessages([result.userMessage, result.assistantMessage]); - await this.persistGeneralAssistantMessage(sessionId, result.assistantMessage); - this.chatTimelineStore.clearActiveTurn(); - this.syncGeneralChatTimelineFromStore(); - this.generalChatPageState.setBusy(false); - this.generalChatPageState.setServiceState(GeneralChatServiceState.Ready); - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.ready')); - } - } catch (err) { - this.generalChatStreamLifecycleController.setRequestInFlight(false); - if (this.generalChatStreamLifecycleController.takeRequestCancelled()) { - const finalMessage = this.generalChatStreamLifecycleController.takePendingFinalMessage(); - if (finalMessage) { - await this.persistGeneralAssistantMessage(sessionId, finalMessage); - this.generalChatPageState.setServiceState(finalMessage.status === 'failed' ? - GeneralChatServiceState.Failed : GeneralChatServiceState.Ready); - } else { - this.generalChatPageState.setServiceState(GeneralChatServiceState.Ready); - } - this.resetGeneralChatNetworkStream(); - this.generalChatPageState.setBusy(false); - return; - } - this.flushGeneralChatNetworkDelta(sessionId); - const partialText = this.generalChatStreamLifecycleController.text(); - const errorText = ConnectionErrorPolicy.errorText(err); - this.generalChatPageState.setServiceState(GeneralChatServiceStatus.fromError(errorText)); - this.resetGeneralChatNetworkStream(); - this.generalChatPageState.setChatInput(rawText); - this.generalChatPageState.setSelectedImages(images); - await this.generalChatDraftLifecycleController.saveNow(sessionId, rawText); - this.chatTimelineStore.setPersistedMessages(await this.generalChatCommandController.loadMessages(sessionId)); - if (partialText.trim().length > 0) { - const interruptedMessage: ChatMessage = { - id: Encoding.randomId('assistant'), - turnId: this.currentActiveTurnId(), - role: 'assistant', - text: partialText, - status: 'failed', - detail: rawText, - timestamp: new Date().toISOString(), - items: this.generalAssistantItems([], partialText) - }; - this.chatTimelineStore.mergePersistedMessages([interruptedMessage]); - await this.persistGeneralAssistantMessage(sessionId, interruptedMessage); - } - this.chatTimelineStore.clearActiveTurn(); - this.syncGeneralChatTimelineFromStore(); - this.generalChatPageState.setStatus(errorText); - this.generalChatPageState.setBusy(false); - } - } - - private beginGeneralChatNetworkStream(sessionId: string): void { - const activeTurn = this.generalChatStreamLifecycleController.begin(sessionId); - this.chatTimelineStore.setActiveTurn(activeTurn); - this.syncGeneralChatTimelineFromStore(); - } - - private appendGeneralChatNetworkDelta(sessionId: string, delta: string): void { - const accepted = this.generalChatStreamLifecycleController.append( - sessionId, - delta, - (): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && this.isGeneralChatVisible(), - (activeTurn: ChatMessage): void => { - this.chatTimelineStore.setActiveTurn(activeTurn); - this.syncGeneralChatTimelineFromStore(); - } - ); - if (accepted) { - this.generalChatPageState.setServiceState(GeneralChatServiceState.Streaming); - } - } - - private flushGeneralChatNetworkDelta(sessionId: string): void { - const activeTurn = this.generalChatStreamLifecycleController.flush( - sessionId, - this.generalChatPageState.activeSession.sessionId === sessionId && this.isGeneralChatVisible() - ); - if (!activeTurn) { - return; - } - this.chatTimelineStore.setActiveTurn(activeTurn); - this.syncGeneralChatTimelineFromStore(); - } - - private finishGeneralChatNetworkStream(sessionId: string, result: GeneralChatSendResult): void { - const currentStreamSessionId = this.generalChatStreamLifecycleController.currentSessionId(); - const isVisibleSession = this.generalChatPageState.activeSession.sessionId === sessionId && - this.isGeneralChatVisible(); - RemoteLogger.info(`general chat finish check session=${this.shortSessionId(sessionId)} current=${this.shortSessionId(currentStreamSessionId)} visible=${isVisibleSession}`); - if (currentStreamSessionId !== sessionId && !isVisibleSession) { - return; - } - this.chatTimelineStore.mergePersistedMessages([result.userMessage, result.assistantMessage]); - this.persistGeneralAssistantMessage(sessionId, result.assistantMessage); - this.chatTimelineStore.clearActiveTurn(); - this.syncGeneralChatTimelineFromStore(); - const finalState = this.chatTimelineStore.snapshot(); - RemoteLogger.info(`general chat finish applied session=${this.shortSessionId(sessionId)} persisted=${finalState.persistedMessages.length} active=${finalState.activeTurn ? finalState.activeTurn.id : 'none'} textLength=${result.assistantMessage.text.length}`); - this.resetGeneralChatNetworkStream(); - this.generalChatPageState.setBusy(false); - this.generalChatPageState.setServiceState(GeneralChatServiceState.Ready); - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.ready')); - } - - private resetGeneralChatNetworkStream(): void { - this.generalChatStreamLifecycleController.reset(); - } - - private generalAssistantItems(tools: RemoteToolStatusResponse[], text: string): ChatMessageItemResponse[] { - const items: ChatMessageItemResponse[] = []; - tools.forEach((tool: RemoteToolStatusResponse) => { - items.push({ - type: 'tool', - tool - }); - }); - if (text.trim().length > 0) { - items.push({ - type: 'text', - content: text - }); - } - return items; - } - - private async persistGeneralAssistantMessage(sessionId: string, message: ChatMessage): Promise { - if (sessionId.length === 0) { - return; - } - await this.generalChatCommandController.recordAssistantMessage(sessionId, message); - } - - private stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - const hasGeneralStream = this.generalChatStreamLifecycleController.hasActiveStream(); - if (!this.isGeneralChatVisible() && !hasGeneralStream) { - return; - } - const hadStream = hasGeneralStream || - (this.isGeneralChatVisible() && this.generalChatPageState.activeTurnMessage.id.length > 0); - const currentStreamSessionId = this.generalChatStreamLifecycleController.currentSessionId(); - if (cancelled && currentStreamSessionId.length > 0) { - this.flushGeneralChatNetworkDelta(currentStreamSessionId); - } - if (hasGeneralStream) { - this.generalChatStreamLifecycleController.markRequestCancelled(); - this.generalChatCommandController.cancelCurrentRequest(); - } - this.generalChatStreamLifecycleController.reset(); - if (cancelled && this.generalChatPageState.activeTurnMessage.id.length > 0) { - const activeTurnMessage = this.generalChatPageState.activeTurnMessage; - const stoppedMessage: ChatMessage = { - id: Encoding.randomId('assistant'), - turnId: this.currentActiveTurnId(), - role: 'assistant', - text: activeTurnMessage.text || (finalStatus === 'failed' ? - RemoteI18n.t('generalChat.interrupted') : RemoteI18n.t('generalChat.stopped')), - status: finalStatus, - detail: finalStatus === 'failed' ? this.latestUserMessageText() : '', - timestamp: new Date().toISOString(), - items: this.generalAssistantItems([], activeTurnMessage.text || '') - }; - this.chatTimelineStore.mergePersistedMessages([stoppedMessage]); - if (this.generalChatStreamLifecycleController.isRequestInFlight()) { - this.generalChatStreamLifecycleController.setPendingFinalMessage(stoppedMessage); - } else { - this.persistGeneralAssistantMessage(this.generalChatPageState.activeSession.sessionId || '', stoppedMessage); - } - this.generalChatPageState.setStatus(finalStatus === 'failed' ? - RemoteI18n.t('generalChat.interrupted') : RemoteI18n.t('status.stopRequested')); - this.generalChatPageState.setServiceState(finalStatus === 'failed' ? - GeneralChatServiceState.Failed : GeneralChatServiceState.Ready); - } - if (this.isGeneralChatVisible()) { - this.chatTimelineStore.clearActiveTurn(); - this.syncGeneralChatTimelineFromStore(); - if (cancelled || hadStream) { - this.generalChatPageState.setBusy(false); - } - } - } - - private async sendChatMessage(): Promise { - if (this.remotePageState.isVoiceListening) { - await this.stopVoiceInput(false); - } - const rawText = this.remotePageState.chatInput.trim(); - const images = this.remotePageState.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.clearComposer(); - const localMessage = RemoteUiState.localUserMessage(text, images); - this.chatTimelineStore.appendOptimisticMessage(localMessage); - const pendingActiveId = this.chatTimelineStore.setPendingActiveTurn(localMessage.id); - this.syncChatTimelineFromStore(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); - this.startPolling(); - this.nudgeChatPolling(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? this.imagePickerService.toRemoteContexts(images) : []; - await this.remoteChatCommandController.sendPreparedMessage( - sessionId, - text, - this.activeSession.agentType, - rawText, - images, - imageContexts, - localMessage.id, - pendingActiveId, - this.isBusy, - true - ); - } - - private async toggleVoiceInput(): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.toggle(getContext(this), this.voiceInputSnapshot(route)); - } - - private async stopVoiceInput(showStatus: boolean): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.stop(this.voiceInputSnapshot(route), showStatus); - } - - private showVoiceInputError(message: string): void { - const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); - this.setVisibleStatusText(text); - try { - this.getUIContext().getPromptAction().showToast({ - message: text, - duration: 2600 - }); - } catch (_err) { - } - } - - private async pickImages(): Promise { - if (this.visibleChatBusy()) { - return; - } - const route = this.currentRoute(); - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - try { - this.setVisibleStatusText(RemoteI18n.t('status.pickImage')); - const picked = await this.imagePickerService.pickImages(3, this.visibleSelectedImages().length); - if (picked.length === 0) { - this.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); - return; - } - this.addSelectedImagesForRoute(route, picked); - this.setVisibleStatusText(RemoteI18n.f('status.imagesSelected', `${this.visibleSelectedImages().length}`)); - } catch (err) { - this.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - private removeSelectedImage(imageId: string): void { - this.removeSelectedImageForRoute(this.currentRoute(), imageId); - } - - private startVisibleGeneralChat(): void { - const rawText = this.generalChatPageState.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.generalChatPageState.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.generalChatPageState.isBusy) { - return; - } - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.generalChatPageState.serviceState); - this.generalChatPageState.setStatus(statusText); - this.showHomeToast(statusText); - return; - } - this.startGeneralChat(text); - } - - private generalChatHomeStatusText(): string { - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Ready || - this.generalChatPageState.serviceState === GeneralChatServiceState.Sending || - this.generalChatPageState.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage( - this.generalChatPageState.serviceState, - this.generalChatPageState.statusText - ); - } - - private prepareNewGeneralChat(): void { - this.stopVoiceInput(false); - this.stopGeneralChatStream(true); - this.generalChatDraftLifecycleController.clearHome(); - this.generalChatPageState.clearComposer(); - this.generalChatPageState.clearActiveSession(); - this.resetGeneralChatTimeline(''); - this.replaceRoute(AppRoute.ChatHome); - } - - private onVisibleChatInputChange(route: AppRoute, value: string): void { - this.setChatInputForRoute(route, value); - if (!this.isGeneralComposerRoute(route)) { - return; - } - this.generalChatDraftLifecycleController.scheduleVisible(value); - } - - private visibleGeneralChatDraftId(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID; - } - return ''; - } - - private persistVisibleGeneralChatDraft(): void { - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); - } - - private async restoreGeneralChatDraft(draftId: string): Promise { - this.generalChatPageState.setChatInput(await this.generalChatDraftLifecycleController.restore(draftId)); - } - - private latestUserMessageText(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.latestUserMessageText(); - } - const candidates = this.messages.concat(this.pendingMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; - } - - private showHomeToast(message: string): void { - try { - this.getUIContext().getPromptAction().showToast({ - message, - duration: 2600 - }); - } catch (_err) { - this.setVisibleStatusText(message); - } - } - - private async stopActiveTask(): Promise { - const sessionId = this.activeSession.sessionId || ''; - if (!sessionId) { - return; - } - await this.remoteChatCommandController.stopTask( - sessionId, - this.activeTurnMessage.id, - this.currentActiveTurnId(), - this.ensureRemoteAvailable() - ); - } - - private async renameActiveSession(title: string): Promise { - const nextTitle = title.trim(); - if ( - !this.activeSession.sessionId || - nextTitle.length === 0 || - nextTitle === this.activeSession.title || - this.isBusy - ) { - return; - } - await this.remoteChatCommandController.renameActiveSession( - this.activeSession, - nextTitle, - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - private async copyMessage(text: string): Promise { - if (text.trim().length === 0) { - return; - } - try { - await this.clipboardService.writeText(text); - this.setRemoteStatusText(RemoteI18n.t('status.messageCopied')); - } catch (err) { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - private async downloadFile(path: string): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteFileDownloadController.download(path, sessionId, this.isBusy, this.ensureRemoteAvailable()); - } - - private retryMessage(text: string): void { - if (this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.setChatInput(text); - this.sendChatMessage(); - } - - private async approveTool(toolId: string, updatedInput?: Object): Promise { - await this.remoteToolActionController.approve( - toolId, - this.activeSession.sessionId || '', - this.isBusy, - this.ensureRemoteAvailable(), - updatedInput - ); - } - - private async rejectTool(toolId: string): Promise { - await this.remoteToolActionController.reject( - toolId, - this.activeSession.sessionId || '', - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - private async cancelTool(toolId: string): Promise { - await this.remoteToolActionController.cancel( - toolId, - this.activeSession.sessionId || '', - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - private async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.remoteToolActionController.answer( - toolId, - this.activeSession.sessionId || '', - this.isBusy, - this.ensureRemoteAvailable(), - answers - ); - } - - private resetChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.knownPollVersion = 0; - this.syncChatTimelineFromStore(); - } - - private resetGeneralChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.syncGeneralChatTimelineFromStore(); - } - - private syncChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshot(); - this.messages = state.persistedMessages; - this.pendingMessages = state.optimisticMessages; - this.activeTurnMessage = state.activeTurn || RemoteUiState.emptyActiveTurn(); - this.modelCatalog = state.modelCatalog; - this.selectedModelId = state.selectedModelId; - this.timelineItems = this.projectedTimelineItems(); - this.remotePageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - this.hasMoreMessages, - this.timelineItems - ); - this.remotePageState.setModelCatalog(this.modelCatalog, this.selectedModelId); - } - - private syncGeneralChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshot(); - const projectedItems = this.chatTimelineStore.project(false); - this.generalChatPageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - false, - projectedItems - ); - const itemSummary = projectedItems.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; - }).join(','); - RemoteLogger.info(`general chat projection revision=${this.generalChatPageState.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - - private startPolling(): void { - this.remoteChatPollingLifecycleController.startActiveSession({ - sessionId: this.activeSession.sessionId || '', - cursor: this.currentChatPollingCursor(), - activeTurn: this.activeTurnMessage - }); - } - - private stopPolling(): void { - this.remoteChatPollingLifecycleController.stop(); - } - - private nudgeChatPolling(): void { - this.remoteChatPollingLifecycleController.nudge(); - } - - private async pollActiveSession(): Promise { - await this.remoteChatPollingLifecycleController.pollNow(); - } - - private currentChatPollingCursor(): RemoteChatPollingCursor { - return { - pollVersion: this.knownPollVersion, - knownMessageCount: this.knownRemoteMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }; - } - - private updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { - this.knownPollVersion = pollVersion; - this.knownRemoteMessageCount = knownMessageCount; - this.remoteChatPollingLifecycleController.updateCursor({ - pollVersion, - knownMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }); - } - - private applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (snapshot.sessionId !== this.activeSession.sessionId || !this.isRoute(AppRoute.RemoteChat)) { - return; - } - this.chatTimelineStore.applySnapshot(snapshot); - this.syncChatTimelineFromStore(); - this.knownPollVersion = snapshot.cursor.pollVersion; - this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; - this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; - if (snapshot.title.length > 0) { - this.activeSession = { - sessionId: this.activeSession.sessionId, - title: snapshot.title, - workspacePath: this.activeSession.workspacePath, - agentType: this.activeSession.agentType - }; - this.remotePageState.setActiveSession(this.activeSession); - } - if (snapshot.modelCatalog) { - this.remoteModelController.applyCatalog(snapshot.modelCatalog); - } - this.setRemoteStatusText(this.hasRunningActiveTurn() - ? RemoteI18n.t('status.desktopProcessing') - : RemoteI18n.t('status.messagesSynced')); - if (snapshot.shouldSyncAfterTurnEnded) { - this.syncAfterTurnEnded(); - } - } - - private hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - private currentActiveTurnId(): string { - const activeTurnMessage = this.isGeneralChatVisible() ? - this.generalChatPageState.activeTurnMessage : this.activeTurnMessage; - if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { - return activeTurnMessage.turnId; - } - const activePrefix = 'active-'; - if (activeTurnMessage.id.indexOf(activePrefix) === 0) { - return activeTurnMessage.id.slice(activePrefix.length); - } - return ''; - } - - private projectedTimelineItems(): ChatTimelineItem[] { - return this.chatTimelineStore.project(this.hasMoreMessages); - } - - private startHeartbeat(): void { - this.remoteActivityLifecycleController.startHeartbeat(); - } - - private stopHeartbeat(): void { - this.remoteActivityLifecycleController.stopHeartbeat(); - } - - private async checkConnectionHealth(): Promise { - if (this.connectionState !== ConnectionState.Connected || this.isBusy) { - return; - } - try { - RemoteLogger.info('heartbeat ping start'); - await this.sessionManager.ping(); - RemoteLogger.info('heartbeat ping ok'); - } catch (err) { - RemoteLogger.warn(`heartbeat ping failed ${ConnectionErrorPolicy.errorText(err)}`); - this.setRemoteConnectionState(ConnectionState.Failed); - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.stopHeartbeat(); - } - } - - private resumeRemoteActivity(): void { - if (!this.hasRemoteBindingForResume()) { - return; - } - this.startHeartbeat(); - if (this.isRoute(AppRoute.RemoteChat) && this.activeSession.sessionId.length > 0) { - this.startPolling(); - this.nudgeChatPolling(); - } - this.verifyConnectionAfterForeground(); - } - - private async verifyConnectionAfterForeground(): Promise { - if (this.isBusy || !this.hasRemoteBindingForResume()) { - return; - } - try { - this.setRemoteConnectionState(ConnectionState.Reconnecting); - RemoteLogger.info('foreground ping start'); - await this.sessionManager.ping(); - this.setRemoteConnectionState(ConnectionState.Connected); - this.setRemoteStatusText(RemoteI18n.t('connection.connected')); - RemoteLogger.info('foreground ping ok'); - if (this.isRoute(AppRoute.RemoteChat) && this.activeSession.sessionId.length > 0) { - this.pollActiveSession(); - } - } catch (err) { - RemoteLogger.warn(`foreground ping failed ${ConnectionErrorPolicy.errorText(err)}`); - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.stopHeartbeat(); - this.setRemoteConnectionState(ConnectionState.Reconnecting); - await this.reconnectAfterForeground(); - } - } - - private async reconnectAfterForeground(): Promise { - const shouldRestoreChat = this.isRoute(AppRoute.RemoteChat) && this.activeSession.sessionId.length > 0; - const previousSession: SessionSummary = { - sessionId: this.activeSession.sessionId, - title: this.activeSession.title, - workspacePath: this.activeSession.workspacePath, - agentType: this.activeSession.agentType - }; - await this.connect(true); - if (this.connectionState !== ConnectionState.Connected) { - return; - } - if (shouldRestoreChat) { - this.activeSession = previousSession; - this.remotePageState.setActiveSession(previousSession); - await this.loadActiveMessages(); - this.startPolling(); - this.nudgeChatPolling(); - } - } - - private hasRemoteBindingForResume(): boolean { - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Parsing && - this.connectionState !== ConnectionState.Pairing && - this.connectionState !== ConnectionState.Disconnected; - } - - private hasRemoteBindingForCodeHome(): boolean { - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - (this.connectionState === ConnectionState.Connected || - this.connectionState === ConnectionState.Reconnecting || - this.connectionState === ConnectionState.Pairing || - this.connectionState === ConnectionState.Parsing); - } - - private shortSessionId(sessionId: string): string { - if (sessionId.length <= 8) { - return sessionId; - } - return sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); - } - - private async syncAfterTurnEnded(): Promise { - if (this.isSyncingAfterTurn) { - return; - } - this.isSyncingAfterTurn = true; - try { - await this.loadActiveMessages(); - } finally { - this.isSyncingAfterTurn = false; - } - } - } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets new file mode 100644 index 0000000000..60b20256cf --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -0,0 +1,327 @@ +import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { AppShell } from './AppShell'; +import { AppSidebar } from './AppSidebar'; +import { ConnectView } from './ConnectView'; +import { ConversationIntent } from './ConversationIntent'; +import { ConversationViewHost } from './ConversationViewHost'; +import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; +import { RemoteCreateSessionView } from './RemoteCreateSessionView'; +import { RemoteHomeView } from './RemoteHomeView'; +import { SettingsSheet } from './SettingsSheet'; +import { PAGE_BG } from './Theme'; +import { AppNavigationBackAction, AppRoute } from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { ConversationViewState } from '../state/ConversationViewState'; + +export class AppRootPresentationActions { + readonly onNavigationBack: (route: AppRoute) => boolean; + readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; + readonly onCloseSidebar: () => void; + readonly onRemoteHome: RemoteHomePresentationActions; + readonly onRemoteCreate: RemoteCreatePresentationActions; + readonly onSidebar: SidebarPresentationActions; + readonly onSettings: SettingsPresentationActions; + readonly onConnect: ConnectPresentationActions; + readonly generalStatus: () => string; + + constructor( + onNavigationBack: (route: AppRoute) => boolean, + onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, + onCloseSidebar: () => void, + onRemoteHome: RemoteHomePresentationActions, + onRemoteCreate: RemoteCreatePresentationActions, + onSidebar: SidebarPresentationActions, + onSettings: SettingsPresentationActions, + onConnect: ConnectPresentationActions, + generalStatus: () => string + ) { + this.onNavigationBack = onNavigationBack; + this.onConversationIntent = onConversationIntent; + this.onCloseSidebar = onCloseSidebar; + this.onRemoteHome = onRemoteHome; + this.onRemoteCreate = onRemoteCreate; + this.onSidebar = onSidebar; + this.onSettings = onSettings; + this.onConnect = onConnect; + this.generalStatus = generalStatus; + } +} + +export class RemoteCreatePresentationActions { + readonly back: () => void; + readonly toggleDevices: () => void; + readonly toggleWorkspaces: () => void; + readonly selectDevice: (device: CloudAccountDevice) => void; + readonly selectWorkspace: (path: string) => void; + readonly draftChanged: (value: string) => void; + readonly send: () => void; + + constructor( + back: () => void, + toggleDevices: () => void, + toggleWorkspaces: () => void, + selectDevice: (device: CloudAccountDevice) => void, + selectWorkspace: (path: string) => void, + draftChanged: (value: string) => void, + send: () => void + ) { + this.back = back; + this.toggleDevices = toggleDevices; + this.toggleWorkspaces = toggleWorkspaces; + this.selectDevice = selectDevice; + this.selectWorkspace = selectWorkspace; + this.draftChanged = draftChanged; + this.send = send; + } +} + +export class RemoteHomePresentationActions { + readonly openSidebar: () => void; readonly connectWorkspace: () => void; + readonly addConnection: () => void; readonly openSettings: () => void; + readonly refresh: () => void; readonly showWorkspaces: () => void; readonly showAssistants: () => void; + readonly selectWorkspace: (path: string) => void; readonly selectAssistant: (path: string) => void; + readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; + readonly queryChanged: (query: string) => void; readonly search: () => void; readonly loadMore: () => void; + readonly reconnect: () => void; readonly disconnect: () => void; readonly clearPairing: () => void; + readonly create: (agentType: string) => void; readonly createAssistant: () => void; + readonly createInWorkspace: (path: string, agentType: string) => void; readonly openSession: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; + + constructor( + openSidebar: () => void, connectWorkspace: () => void, addConnection: () => void, openSettings: () => void, + refresh: () => void, showWorkspaces: () => void, showAssistants: () => void, + selectWorkspace: (path: string) => void, selectAssistant: (path: string) => void, + cancelWorkspace: () => void, cancelAssistant: () => void, queryChanged: (query: string) => void, + search: () => void, loadMore: () => void, reconnect: () => void, disconnect: () => void, + clearPairing: () => void, create: (agentType: string) => void, createAssistant: () => void, + createInWorkspace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, + deleteSession: (session: RemoteSession) => void + ) { + this.openSidebar = openSidebar; this.connectWorkspace = connectWorkspace; this.addConnection = addConnection; + this.openSettings = openSettings; this.refresh = refresh; this.showWorkspaces = showWorkspaces; + this.showAssistants = showAssistants; this.selectWorkspace = selectWorkspace; this.selectAssistant = selectAssistant; + this.cancelWorkspace = cancelWorkspace; this.cancelAssistant = cancelAssistant; this.queryChanged = queryChanged; + this.search = search; this.loadMore = loadMore; this.reconnect = reconnect; this.disconnect = disconnect; + this.clearPairing = clearPairing; this.create = create; this.createAssistant = createAssistant; + this.createInWorkspace = createInWorkspace; this.openSession = openSession; this.deleteSession = deleteSession; + } +} + +export class SidebarPresentationActions { + readonly close: () => void; readonly newChat: () => void; readonly enterCode: () => void; + readonly settings: () => void; readonly openSession: (session: RemoteSession) => void; + readonly archive: (session: RemoteSession, archived: boolean) => void; + readonly exportSession: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; + constructor( + close: () => void, newChat: () => void, enterCode: () => void, settings: () => void, + openSession: (session: RemoteSession) => void, archive: (session: RemoteSession, archived: boolean) => void, + exportSession: (session: RemoteSession) => void, deleteSession: (session: RemoteSession) => void + ) { + this.close = close; this.newChat = newChat; this.enterCode = enterCode; this.settings = settings; + this.openSession = openSession; this.archive = archive; this.exportSession = exportSession; this.deleteSession = deleteSession; + } +} + +export class SettingsPresentationActions { + readonly close: () => void; readonly addConnection: () => void; readonly disconnect: () => void; + readonly reconnect: () => void; + readonly openAccount: () => void; + readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; + readonly cloudSync: () => Promise; + readonly cloudLogout: () => Promise; + readonly cloudListDevices: () => Promise; + readonly getPermissionMode: () => Promise; + readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; + constructor( + close: () => void, addConnection: () => void, disconnect: () => void, reconnect: () => void, + openAccount: () => void, + cloudLogin: (relayUrl: string, username: string, password: string) => Promise, + cloudSync: () => Promise, cloudLogout: () => Promise, + cloudListDevices: () => Promise, + getPermissionMode: () => Promise, + setPermissionMode: (mode: RemotePermissionMode) => Promise, + saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise + ) { + this.close = close; this.addConnection = addConnection; this.disconnect = disconnect; + this.reconnect = reconnect; this.openAccount = openAccount; this.cloudLogin = cloudLogin; this.cloudSync = cloudSync; + this.cloudLogout = cloudLogout; this.cloudListDevices = cloudListDevices; + this.getPermissionMode = getPermissionMode; this.setPermissionMode = setPermissionMode; + this.saveGeneral = saveGeneral; + } +} + +export class ConnectPresentationActions { + readonly back: () => void; readonly connect: (password?: string) => void; readonly clearPairing: () => void; + readonly urlChanged: (url: string) => void; readonly userChanged: (user: string) => void; + readonly detected: (url: string) => boolean; readonly inputVisible: (visible: boolean) => void; + readonly paste: () => void; readonly scan: () => void; + readonly cloudListDevices: () => Promise; + readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; + constructor( + back: () => void, connect: (password?: string) => void, clearPairing: () => void, + urlChanged: (url: string) => void, userChanged: (user: string) => void, + detected: (url: string) => boolean, inputVisible: (visible: boolean) => void, + paste: () => void, scan: () => void, cloudListDevices: () => Promise, + cloudSelectDevice: (device: CloudAccountDevice) => Promise + ) { + this.back = back; this.connect = connect; this.clearPairing = clearPairing; + this.urlChanged = urlChanged; this.userChanged = userChanged; this.detected = detected; + this.inputVisible = inputVisible; this.paste = paste; this.scan = scan; + this.cloudListDevices = cloudListDevices; this.cloudSelectDevice = cloudSelectDevice; + } +} + +@ComponentV2 +export struct AppRootPresentation { + @Param shellState: AppShellState = new AppShellState(); + @Param navigationStack: NavPathStack = new NavPathStack(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param deviceId: string = ''; + @Param currentRoute: AppRoute = AppRoute.ChatHome; + @Param actions: AppRootPresentationActions = new AppRootPresentationActions( + () => false, () => {}, () => {}, + new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, + () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, + () => {}, () => {}, () => {}, () => {}, () => {}), + new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), + new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), + new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), + new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, + () => {}, async (): Promise => [], async (_device: CloudAccountDevice): Promise => {}), + () => '' + ); + + build() { + Stack() { + AppShell({ shellState: this.shellState, content: () => { this.NavigationContent(); }, + sidebar: () => { this.SidebarContent(); }, settings: () => { this.SettingsContent(); }, + connect: () => { this.ConnectContent(); }, onCloseSidebar: this.actions.onCloseSidebar }) + }.width('100%').height('100%') + } + + @Builder NavigationContent() { + Navigation(this.navigationStack) { this.RouteContent(AppRoute.ChatHome) } + .width('100%').height('100%').hideTitleBar(true).mode(NavigationMode.Stack) + .navDestination(this.NavigationDestination) + } + + @Builder NavigationDestination(name: string) { + NavDestination() { this.RouteContent(name as AppRoute) }.hideTitleBar(true).backgroundColor(PAGE_BG) + .onBackPressed(() => this.actions.onNavigationBack(name as AppRoute)) + } + + @Builder RouteContent(route: AppRoute) { + Column() { + if (route === AppRoute.RemoteHome) { + RemoteHomeView({ pageState: this.remotePageState, isBusy: this.remotePageState.isBusy, + onOpenSidebar: this.actions.onRemoteHome.openSidebar, + onConnectWorkspace: this.actions.onRemoteHome.connectWorkspace, + onAddConnection: this.actions.onRemoteHome.addConnection, + onOpenRemoteSettings: this.actions.onRemoteHome.openSettings, + onRefresh: this.actions.onRemoteHome.refresh, + onShowWorkspaces: this.actions.onRemoteHome.showWorkspaces, + onShowAssistants: this.actions.onRemoteHome.showAssistants, + onSelectWorkspace: this.actions.onRemoteHome.selectWorkspace, + onSelectAssistant: this.actions.onRemoteHome.selectAssistant, + onCancelWorkspacePicker: this.actions.onRemoteHome.cancelWorkspace, + onCancelAssistantPicker: this.actions.onRemoteHome.cancelAssistant, + onSessionQueryChange: this.actions.onRemoteHome.queryChanged, + onSearchSessions: this.actions.onRemoteHome.search, + onLoadMoreSessions: this.actions.onRemoteHome.loadMore, + onReconnect: this.actions.onRemoteHome.reconnect, + onDisconnect: this.actions.onRemoteHome.disconnect, + onClearPairing: this.actions.onRemoteHome.clearPairing, + onCreate: this.actions.onRemoteHome.create, + onCreateAssistantSession: this.actions.onRemoteHome.createAssistant, + onCreateInWorkspace: this.actions.onRemoteHome.createInWorkspace, + onOpenSession: this.actions.onRemoteHome.openSession, + onDeleteSession: this.actions.onRemoteHome.deleteSession }) + } else if (route === AppRoute.RemoteCreate) { + RemoteCreateSessionView({ + state: this.remoteCreateState, + onBack: this.actions.onRemoteCreate.back, + onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, + onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, + onSelectDevice: this.actions.onRemoteCreate.selectDevice, + onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), + onDraftChange: this.actions.onRemoteCreate.draftChanged, + onSend: this.actions.onRemoteCreate.send + }) + } else { + ConversationViewHost({ + viewState: ConversationViewState.project(route, this.remotePageState, this.generalPageState, + this.actions.generalStatus()), + onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(route, intent) + }) + } + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder SidebarContent() { + AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.currentRoute === AppRoute.ChatHome || this.currentRoute === AppRoute.GeneralChat ? + this.generalPageState.activeSession.sessionId : '', connectionState: this.remotePageState.connectionState, + activeSection: this.currentRoute === AppRoute.RemoteHome || this.currentRoute === AppRoute.RemoteChat ? 'remote' : 'chat', + onClose: this.actions.onSidebar.close, + onNewChat: this.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, + onOpenSettings: this.actions.onSidebar.settings, onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession }) + } + + @Builder SettingsContent() { + if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { + RemoteControlSettingsSheet({ desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, + userId: this.remotePageState.userId, accountUsername: this.remotePageState.accountUsername, + accountUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + connectionState: this.remotePageState.connectionState, statusText: this.remotePageState.statusText, + isBusy: this.remotePageState.isBusy, onClose: this.actions.onSettings.close, + 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, + setPermissionMode: this.actions.onSettings.setPermissionMode, + openProfileOnAppear: this.shellState.settingsMode === 'account', + onDisconnect: this.actions.onSettings.disconnect, onReconnect: this.actions.onSettings.reconnect }) + } else { + SettingsSheet({ generalChatApiUrl: this.generalPageState.apiUrl, generalChatModelName: this.generalPageState.modelName, + hasGeneralChatApiKey: this.generalPageState.hasApiKey, + accountUsername: this.remotePageState.accountUsername, + authenticatedUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + onOpenAccount: this.actions.onSettings.openAccount, + onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, + onClose: this.actions.onSettings.close }) + } + } + + @Builder ConnectContent() { + ConnectView({ remoteUrl: this.remotePageState.remoteUrl, userId: this.remotePageState.userId, + showRemoteUrlInput: this.remotePageState.showRemoteUrlInput, statusText: this.remotePageState.statusText, + connectionState: this.remotePageState.connectionState, connectionFailureKind: this.remotePageState.connectionFailureKind, + isBusy: this.remotePageState.isBusy, isConnected: this.remotePageState.connectionState === 'connected', + desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, deviceId: this.deviceId, + accountUserId: this.remotePageState.accountUserId, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + requiresAccountAuth: this.remotePageState.requiresAccountAuth, accountUsername: this.remotePageState.accountUsername, + startWithScanner: true, + onBack: this.actions.onConnect.back, onConnect: this.actions.onConnect.connect, + onClearPairing: this.actions.onConnect.clearPairing, onRemoteUrlChange: this.actions.onConnect.urlChanged, + onUserIdChange: this.actions.onConnect.userChanged, onRemoteUrlDetected: this.actions.onConnect.detected, + onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, + onPasteRemoteUrl: this.actions.onConnect.paste, onScanRemoteUrl: this.actions.onConnect.scan, + cloudListDevices: this.actions.onConnect.cloudListDevices, + cloudSelectDevice: this.actions.onConnect.cloudSelectDevice }) + .width('100%').height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 0c70b62b15..6e42f3b6e0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,4 +1,4 @@ -import { ChatMessage, ChatMessageItemResponse, ImageAttachment, RemoteQuestionAnswerPayload, RemoteToolStatusResponse } from '../../model/RemoteModels'; +import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ACCENT, CARD, INK, LINE, MUTED, RED, SOFT } from './Theme'; import { FileReferenceCard } from './FileReferenceCard'; @@ -14,13 +14,18 @@ interface FileReference { label: string; } +interface SubagentTaskInput { + description?: string; + title?: string; + prompt?: string; +} interface StructuredRenderGroup { type: string; - items: ChatMessageItemResponse[]; + items: ConversationUiMessageItem[]; itemStatuses: string[]; itemStreaming: boolean[]; itemChildActiveScopes: boolean[]; - tools: RemoteToolStatusResponse[]; + tools: ConversationUiToolStatus[]; key: string; path: string; } @@ -32,36 +37,36 @@ interface ActivityGroupStats { otherCount: number; } -@Component +@ComponentV2 export struct ChatMessageBubble { - @Prop item: ChatMessage = { + @Param item: ConversationUiMessage = { id: '', role: 'assistant', text: '', status: '', detail: '' }; - @Prop isStreaming: boolean = false; - @Prop isFinalizing: boolean = false; - @Prop isBusy: boolean = false; - @Prop downloadingFilePath: string = ''; - @Prop downloadedFilePath: string = ''; - @Prop fileDownloadStatus: string = ''; - onApproveTool: (toolId: string, updatedInput?: Object) => void = + @Param isStreaming: boolean = false; + @Param isFinalizing: boolean = false; + @Param isBusy: boolean = false; + @Param downloadingFilePath: string = ''; + @Param downloadedFilePath: string = ''; + @Param fileDownloadStatus: string = ''; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; - onRejectTool: (toolId: string) => void = (_toolId: string) => {}; - onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = - (_toolId: string, _answers: RemoteQuestionAnswerPayload) => {}; - onCopyMessage: (text: string) => void = (_text: string) => {}; - onRetryMessage: (text: string) => void = (_text: string) => {}; - onDownloadFile: (path: string) => void = (_path: string) => {}; - @State expandedActivityPath: string = ''; - @State typingPhase: number = 0; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + @Local expandedActivityPath: string = ''; + @Local typingPhase: number = 0; private typingTimerId: number = 0; aboutToAppear(): void { - if (!this.shouldShowTypingDots(this.item)) { + if (!this.shouldShowTypingDots(this.item) && !this.hasRunningSubagentTask(this.item)) { return; } this.typingTimerId = setInterval(() => { @@ -166,7 +171,7 @@ export struct ChatMessageBubble { } @Builder - FinalContent(item: ChatMessage) { + FinalContent(item: ConversationUiMessage) { this.MessageText(this.finalMessageText(item), false, `${item.id}-final`) if (this.fileReferences(this.finalMessageText(item)).length > 0) { this.FileCards(this.finalMessageText(item)) @@ -174,7 +179,7 @@ export struct ChatMessageBubble { } @Builder - ProcessContent(item: ChatMessage) { + ProcessContent(item: ConversationUiMessage) { if (this.shouldRenderStructuredItems(item)) { this.StructuredItems(item.items || [], this.shouldOmitStructuredThinking(item)) if (this.uncoveredTools(item).length > 0) { @@ -227,7 +232,7 @@ export struct ChatMessageBubble { } @Builder - StructuredItems(items: ChatMessageItemResponse[], omitActiveThinking: boolean = false) { + StructuredItems(items: ConversationUiMessageItem[], omitActiveThinking: boolean = false) { Column({ space: 10 }) { ForEach(this.structuredGroups(items, 'item'), (group: StructuredRenderGroup) => { this.StructuredGroup(group, omitActiveThinking) @@ -243,7 +248,7 @@ export struct ChatMessageBubble { } else if (group.type === 'tool_group') { this.Tools(group.tools) if (group.items.length > 0) { - ForEach(group.items, (entry: ChatMessageItemResponse, index: number) => { + ForEach(group.items, (entry: ConversationUiMessageItem, index: number) => { this.StructuredItem( entry, `${group.path}-adjacent-${index}`, @@ -251,7 +256,7 @@ export struct ChatMessageBubble { group.itemStreaming[index] || false, group.itemChildActiveScopes[index] || false ) - }, (entry: ChatMessageItemResponse, index: number) => { + }, (entry: ConversationUiMessageItem, index: number) => { return this.structuredItemKey(entry, `${group.path}-adjacent-${index}`); }) } @@ -272,7 +277,7 @@ export struct ChatMessageBubble { if (this.activityGroupTools(group).length > 0) { this.Tools(this.activityGroupTools(group)) } - ForEach(group.items, (entry: ChatMessageItemResponse, index: number) => { + ForEach(group.items, (entry: ConversationUiMessageItem, index: number) => { if (!omitActiveThinking && this.isRenderableActivityThinking(entry, group.itemStatuses[index] || '', group.itemStreaming[index] || false)) { this.Thinking( @@ -282,7 +287,7 @@ export struct ChatMessageBubble { group.itemStreaming[index] || false ) } - }, (entry: ChatMessageItemResponse, index: number) => { + }, (entry: ConversationUiMessageItem, index: number) => { return this.structuredItemKey(entry, `${group.path}-activity-thinking-${index}`); }) } @@ -291,7 +296,7 @@ export struct ChatMessageBubble { @Builder StructuredItem( - entry: ChatMessageItemResponse, + entry: ConversationUiMessageItem, path: string, statusOverride: string = '', streaming: boolean = false, @@ -322,7 +327,7 @@ export struct ChatMessageBubble { } @Builder - SubagentGroup(entry: ChatMessageItemResponse, path: string, activeScope: boolean = false) { + SubagentGroup(entry: ConversationUiMessageItem, path: string, activeScope: boolean = false) { Column({ space: 8 }) { Row({ space: 8 }) { Text('↳') @@ -339,6 +344,9 @@ export struct ChatMessageBubble { .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') + if (entry.tool && this.isRunningTool(entry.tool)) { + this.TypingDots() + } if (entry.content && entry.content.trim().length > 0 && !this.isTextEntry(entry) && !this.isThinkingEntry(entry)) { this.MessageText(entry.content, activeScope && (!entry.subItems || entry.subItems.length === 0), `${this.item.id}-${path}-subagent`) } @@ -354,7 +362,7 @@ export struct ChatMessageBubble { } @Builder - ChildItems(items: ChatMessageItemResponse[], parentPath: string, activeScope: boolean = false) { + ChildItems(items: ConversationUiMessageItem[], parentPath: string, activeScope: boolean = false) { Column({ space: 8 }) { ForEach(this.structuredGroups(items, parentPath, activeScope), (group: StructuredRenderGroup) => { this.StructuredGroup(group) @@ -380,7 +388,7 @@ export struct ChatMessageBubble { } @Builder - Tools(tools: RemoteToolStatusResponse[]) { + Tools(tools: ConversationUiToolStatus[]) { ToolStatusList({ tools, isBusy: this.isBusy, @@ -393,7 +401,7 @@ export struct ChatMessageBubble { onCancelTool: (toolId: string) => { this.onCancelTool(toolId); }, - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => { + onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { this.onAnswerQuestion(toolId, answers); } }) @@ -433,9 +441,9 @@ export struct ChatMessageBubble { } @Builder - MessageImages(images: ImageAttachment[]) { + MessageImages(images: ConversationUiImage[]) { Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ImageAttachment) => { + ForEach(images, (image: ConversationUiImage) => { Image(image.data_url) .width(92) .height(92) @@ -443,7 +451,7 @@ export struct ChatMessageBubble { .borderRadius(14) .border({ width: 1, color: LINE }) .margin({ right: 8, bottom: 8 }) - }, (image: ImageAttachment) => image.name) + }, (image: ConversationUiImage) => image.name) } .width('100%') } @@ -488,7 +496,7 @@ export struct ChatMessageBubble { .width('100%') } - private visibleMessageText(item: ChatMessage): string { + private visibleMessageText(item: ConversationUiMessage): string { const text = item.text.trim(); if (text === '(空消息)' || text === '(empty message)') { return ''; @@ -496,7 +504,7 @@ export struct ChatMessageBubble { return text; } - private shouldShowTypingDots(item: ChatMessage): boolean { + private shouldShowTypingDots(item: ConversationUiMessage): boolean { if (item.role === 'user' || (item.status || '').toLowerCase() !== 'active') { return false; } @@ -509,38 +517,38 @@ export struct ChatMessageBubble { return this.item.role !== 'user' && (this.isStreaming || (this.item.status || '').toLowerCase() === 'active'); } - private shouldRenderStructuredItems(item: ChatMessage): boolean { + private shouldRenderStructuredItems(item: ConversationUiMessage): boolean { if (item.role === 'user' || !item.items || item.items.length === 0) { return false; } - return item.items.some((entry: ChatMessageItemResponse) => { + return item.items.some((entry: ConversationUiMessageItem) => { return this.isRenderableStructuredEntry(entry); }); } - private uncoveredTools(item: ChatMessage): RemoteToolStatusResponse[] { + private uncoveredTools(item: ConversationUiMessage): ConversationUiToolStatus[] { return this.toolsNotCoveredByItems(item.tools || [], item.items || []); } - private shouldPinThinkingToBottom(item: ChatMessage): boolean { + private shouldPinThinkingToBottom(item: ConversationUiMessage): boolean { return this.isStreamingAssistant() && !this.hasVisibleAssistantOutput(item) && this.currentThinkingText(item).length > 0; } - private pinnedThinkingText(item: ChatMessage): string { + private pinnedThinkingText(item: ConversationUiMessage): string { if (!this.shouldPinThinkingToBottom(item)) { return ''; } return this.currentThinkingText(item); } - private shouldOmitStructuredThinking(item: ChatMessage): boolean { + private shouldOmitStructuredThinking(item: ConversationUiMessage): boolean { return this.shouldPinThinkingToBottom(item) || (this.isStreamingAssistant() && this.hasVisibleAssistantOutput(item)); } - private hasVisibleAssistantOutput(item: ChatMessage): boolean { + private hasVisibleAssistantOutput(item: ConversationUiMessage): boolean { if (this.lastTopLevelText(item.items || []).length > 0) { return true; } @@ -548,7 +556,7 @@ export struct ChatMessageBubble { return visible.length > 0 && visible !== (item.thinking || '').trim(); } - private currentThinkingText(item: ChatMessage): string { + private currentThinkingText(item: ConversationUiMessage): string { const structured = this.lastThinkingText(item.items || []); if (structured.length > 0) { return structured; @@ -556,7 +564,7 @@ export struct ChatMessageBubble { return (item.thinking || '').trim(); } - private lastThinkingText(items: ChatMessageItemResponse[]): string { + private lastThinkingText(items: ConversationUiMessageItem[]): string { for (let index = items.length - 1; index >= 0; index--) { const entry = items[index]; if (this.isThinkingEntry(entry)) { @@ -572,7 +580,7 @@ export struct ChatMessageBubble { return ''; } - private shouldRenderFinalOnly(item: ChatMessage): boolean { + private shouldRenderFinalOnly(item: ConversationUiMessage): boolean { if (item.role === 'user') { return false; } @@ -585,7 +593,7 @@ export struct ChatMessageBubble { return this.finalMessageText(item).length > 0; } - private isFinishedAssistant(item: ChatMessage): boolean { + private isFinishedAssistant(item: ConversationUiMessage): boolean { const status = (item.status || '').toLowerCase(); return status === 'done' || status === 'completed' || @@ -594,7 +602,7 @@ export struct ChatMessageBubble { status.length === 0; } - private finalMessageText(item: ChatMessage): string { + private finalMessageText(item: ConversationUiMessage): string { const structuredText = this.lastTopLevelText(item.items || []); if (structuredText.length > 0) { return structuredText; @@ -606,7 +614,7 @@ export struct ChatMessageBubble { return ''; } - private lastTopLevelText(items: ChatMessageItemResponse[]): string { + private lastTopLevelText(items: ConversationUiMessageItem[]): string { for (let index = items.length - 1; index >= 0; index--) { if (this.isMainTextEntry(items[index])) { const text = (items[index].content || '').trim(); @@ -618,12 +626,12 @@ export struct ChatMessageBubble { return ''; } - private hasVisibleProcessAttention(item: ChatMessage): boolean { + private hasVisibleProcessAttention(item: ConversationUiMessage): boolean { return this.hasAttentionTools(item.tools || []) || this.hasAttentionItems(item.items || []); } - private hasAttentionItems(items: ChatMessageItemResponse[]): boolean { - return items.some((entry: ChatMessageItemResponse) => { + private hasAttentionItems(items: ConversationUiMessageItem[]): boolean { + return items.some((entry: ConversationUiMessageItem) => { if (entry.tool && this.isAttentionTool(entry.tool)) { return true; } @@ -631,13 +639,13 @@ export struct ChatMessageBubble { }); } - private hasAttentionTools(tools: RemoteToolStatusResponse[]): boolean { - return tools.some((tool: RemoteToolStatusResponse) => { + private hasAttentionTools(tools: ConversationUiToolStatus[]): boolean { + return tools.some((tool: ConversationUiToolStatus) => { return this.isAttentionTool(tool); }); } - private isAttentionTool(tool: RemoteToolStatusResponse): boolean { + private isAttentionTool(tool: ConversationUiToolStatus): boolean { return this.hasToolError(tool) || this.isPendingTool(tool) || this.isRunningTool(tool) || @@ -645,14 +653,14 @@ export struct ChatMessageBubble { } private structuredGroups( - items: ChatMessageItemResponse[], + items: ConversationUiMessageItem[], prefix: string, activeScope: boolean = this.isStreamingAssistant() ): StructuredRenderGroup[] { const groups: StructuredRenderGroup[] = []; - let toolBuffer: RemoteToolStatusResponse[] = []; + let toolBuffer: ConversationUiToolStatus[] = []; let toolStart = 0; - let activityItems: ChatMessageItemResponse[] = []; + let activityItems: ConversationUiMessageItem[] = []; let activityStatuses: string[] = []; let activityStreaming: boolean[] = []; let activityChildScopes: boolean[] = []; @@ -682,7 +690,7 @@ export struct ChatMessageBubble { return; } if (!activityHasThinking) { - activityItems.forEach((entry: ChatMessageItemResponse, offset: number) => { + activityItems.forEach((entry: ConversationUiMessageItem, offset: number) => { if (entry.tool) { if (toolBuffer.length === 0) { toolStart = activityStart + offset; @@ -721,7 +729,7 @@ export struct ChatMessageBubble { activityHasThinking = false; }; - items.forEach((entry: ChatMessageItemResponse, index: number) => { + items.forEach((entry: ConversationUiMessageItem, index: number) => { if (this.shouldFoldIntoActivityGroup(entry)) { if (activityItems.length === 0) { activityStart = index; @@ -761,7 +769,7 @@ export struct ChatMessageBubble { return groups; } - private lastRenderableItemIndex(items: ChatMessageItemResponse[]): number { + private lastRenderableItemIndex(items: ConversationUiMessageItem[]): number { for (let index = items.length - 1; index >= 0; index--) { const entry = items[index]; if (this.isRenderableStructuredEntry(entry)) { @@ -771,7 +779,7 @@ export struct ChatMessageBubble { return -1; } - private itemStatusForEntry(entry: ChatMessageItemResponse, index: number, activeItemIndex: number): string { + private itemStatusForEntry(entry: ConversationUiMessageItem, index: number, activeItemIndex: number): string { if (this.isThinkingEntry(entry) && index === activeItemIndex && this.isStreamingAssistant()) { return this.item.status || 'active'; } @@ -781,7 +789,7 @@ export struct ChatMessageBubble { return ''; } - private itemShouldStream(entry: ChatMessageItemResponse, index: number, activeItemIndex: number): boolean { + private itemShouldStream(entry: ConversationUiMessageItem, index: number, activeItemIndex: number): boolean { if (index !== activeItemIndex || !this.isStreamingAssistant()) { return false; } @@ -789,12 +797,12 @@ export struct ChatMessageBubble { (this.isSubagentEntry(entry) && (!entry.subItems || entry.subItems.length === 0)); } - private itemChildActiveScope(entry: ChatMessageItemResponse, index: number, activeItemIndex: number): boolean { + private itemChildActiveScope(entry: ConversationUiMessageItem, index: number, activeItemIndex: number): boolean { return index === activeItemIndex && this.isStreamingAssistant() && !!entry.subItems && entry.subItems.length > 0; } - private shouldFoldIntoActivityGroup(entry: ChatMessageItemResponse): boolean { + private shouldFoldIntoActivityGroup(entry: ConversationUiMessageItem): boolean { if (this.isThinkingEntry(entry)) { return true; } @@ -804,7 +812,7 @@ export struct ChatMessageBubble { return this.shouldFoldToolWithThinking(entry.tool); } - private shouldFoldToolWithThinking(tool: RemoteToolStatusResponse): boolean { + private shouldFoldToolWithThinking(tool: ConversationUiToolStatus): boolean { if (!this.isCollapsibleActivityTool(tool)) { return false; } @@ -814,12 +822,12 @@ export struct ChatMessageBubble { return this.isCompletedTool(tool) || this.isCancelledTool(tool); } - private isCollapsibleActivityTool(tool: RemoteToolStatusResponse): boolean { + private isCollapsibleActivityTool(tool: ConversationUiToolStatus): boolean { const kind = this.activityToolKind(tool); return kind === 'read' || kind === 'search'; } - private activityToolKind(tool: RemoteToolStatusResponse): string { + private activityToolKind(tool: ConversationUiToolStatus): string { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); if (raw === 'Read' || raw === 'LS' || @@ -869,14 +877,14 @@ export struct ChatMessageBubble { return parts.join(' · '); } - private activityGroupStats(items: ChatMessageItemResponse[]): ActivityGroupStats { + private activityGroupStats(items: ConversationUiMessageItem[]): ActivityGroupStats { const stats: ActivityGroupStats = { thinkingCount: 0, readCount: 0, searchCount: 0, otherCount: 0 }; - items.forEach((entry: ChatMessageItemResponse) => { + items.forEach((entry: ConversationUiMessageItem) => { if (this.isThinkingEntry(entry)) { stats.thinkingCount += 1; return; @@ -895,9 +903,9 @@ export struct ChatMessageBubble { return stats; } - private activityGroupTools(group: StructuredRenderGroup): RemoteToolStatusResponse[] { - const tools: RemoteToolStatusResponse[] = []; - group.items.forEach((entry: ChatMessageItemResponse) => { + private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { + const tools: ConversationUiToolStatus[] = []; + group.items.forEach((entry: ConversationUiMessageItem) => { if (entry.tool) { tools.push(entry.tool); } @@ -906,7 +914,7 @@ export struct ChatMessageBubble { } private isRenderableActivityThinking( - entry: ChatMessageItemResponse, + entry: ConversationUiMessageItem, status: string, streaming: boolean ): boolean { @@ -920,21 +928,21 @@ export struct ChatMessageBubble { return streaming || normalized === 'active' || normalized === 'running'; } - private stableToolHash(tools: RemoteToolStatusResponse[]): string { + private stableToolHash(tools: ConversationUiToolStatus[]): string { const parts: string[] = []; - tools.forEach((tool: RemoteToolStatusResponse) => { + tools.forEach((tool: ConversationUiToolStatus) => { parts.push(`${tool.id || ''}:${tool.name || ''}:${tool.status || ''}`); }); return this.stableTextHash(parts.join('|')); } private toolsNotCoveredByItems( - tools: RemoteToolStatusResponse[], - items: ChatMessageItemResponse[] - ): RemoteToolStatusResponse[] { + tools: ConversationUiToolStatus[], + items: ConversationUiMessageItem[] + ): ConversationUiToolStatus[] { const coveredTools = this.itemToolCoverageGroups(items); const matched: boolean[] = coveredTools.map((_keys: string[]) => false); - return tools.filter((tool: RemoteToolStatusResponse) => { + return tools.filter((tool: ConversationUiToolStatus) => { const matchIndex = this.matchCoveredToolIndex(this.toolCoverageKeys(tool), coveredTools, matched); if (matchIndex >= 0) { matched[matchIndex] = true; @@ -944,9 +952,9 @@ export struct ChatMessageBubble { }); } - private itemToolCoverageGroups(items: ChatMessageItemResponse[]): string[][] { + private itemToolCoverageGroups(items: ConversationUiMessageItem[]): string[][] { const groups: string[][] = []; - items.forEach((entry: ChatMessageItemResponse) => { + items.forEach((entry: ConversationUiMessageItem) => { if (entry.tool) { groups.push(this.toolCoverageKeys(entry.tool)); } @@ -974,7 +982,7 @@ export struct ChatMessageBubble { return -1; } - private toolCoverageKeys(tool: RemoteToolStatusResponse): string[] { + private toolCoverageKeys(tool: ConversationUiToolStatus): string[] { const keys: string[] = []; const toolId = tool.id || ''; if (toolId.length > 0) { @@ -987,7 +995,7 @@ export struct ChatMessageBubble { return keys; } - private toolFingerprint(tool: RemoteToolStatusResponse, includeStatus: boolean): string { + private toolFingerprint(tool: ConversationUiToolStatus, includeStatus: boolean): string { const parts: string[] = [ this.normalizedToolName(tool), includeStatus ? (tool.status || '') : '', @@ -1003,7 +1011,7 @@ export struct ChatMessageBubble { return this.stableTextHash(parts.join('|')); } - private hasToolFingerprintData(tool: RemoteToolStatusResponse): boolean { + private hasToolFingerprintData(tool: ConversationUiToolStatus): boolean { return (tool.input_preview || '').length > 0 || (tool.result_preview || '').length > 0 || (tool.error_preview || '').length > 0 || @@ -1024,24 +1032,24 @@ export struct ChatMessageBubble { } } - private isRenderableStructuredEntry(entry: ChatMessageItemResponse): boolean { + private isRenderableStructuredEntry(entry: ConversationUiMessageItem): boolean { if (this.isThinkingEntry(entry) || this.isTextEntry(entry) || this.isSubagentEntry(entry) || !!entry.tool) { return true; } - return !!entry.subItems && entry.subItems.some((child: ChatMessageItemResponse) => { + return !!entry.subItems && entry.subItems.some((child: ConversationUiMessageItem) => { return this.isRenderableStructuredEntry(child); }); } - private isThinkingEntry(entry: ChatMessageItemResponse): boolean { + private isThinkingEntry(entry: ConversationUiMessageItem): boolean { return (entry.type || '').toLowerCase() === 'thinking' && (entry.content || '').trim().length > 0; } - private isTextEntry(entry: ChatMessageItemResponse): boolean { + private isTextEntry(entry: ConversationUiMessageItem): boolean { return this.isMainTextEntry(entry); } - private isMainTextEntry(entry: ChatMessageItemResponse): boolean { + private isMainTextEntry(entry: ConversationUiMessageItem): boolean { const type = (entry.type || '').toLowerCase(); if ((entry.content || '').trim().length === 0) { return false; @@ -1052,23 +1060,52 @@ export struct ChatMessageBubble { return type === 'text' || type === 'message' || type.length === 0; } - private isSubagentEntry(entry: ChatMessageItemResponse): boolean { + private isSubagentEntry(entry: ConversationUiMessageItem): boolean { const type = (entry.type || '').toLowerCase(); - return entry.is_subagent === true || type === 'subagent' || type === 'agent'; + return entry.is_subagent === true || type === 'subagent' || type === 'agent' || + (!!entry.tool && this.normalizedToolName(entry.tool) === 'task'); } - private subagentTitle(entry: ChatMessageItemResponse): string { + private subagentTitle(entry: ConversationUiMessageItem): string { const content = (entry.content || '').trim(); if (content.length > 0 && content.length <= 80) { return content; } + if (entry.tool && this.normalizedToolName(entry.tool) === 'task') { + const title = this.subagentTaskTitle(entry.tool); + if (title.length > 0) { + return title; + } + } if (entry.tool && entry.tool.name) { return entry.tool.name; } return 'Subagent'; } - private structuredItemKey(entry: ChatMessageItemResponse, path: string): string { + private subagentTaskTitle(tool: ConversationUiToolStatus): string { + const raw = tool.input_preview || ''; + if (raw.length > 0) { + try { + const input = JSON.parse(raw) as SubagentTaskInput; + const title = input.description || input.title || ''; + if (title.trim().length > 0) { + return title.trim(); + } + } catch (_err) { + } + } + return ''; + } + + private hasRunningSubagentTask(item: ConversationUiMessage): boolean { + return (item.items || []).some((entry: ConversationUiMessageItem) => { + return !!entry.tool && this.normalizedToolName(entry.tool) === 'task' && + this.isRunningTool(entry.tool); + }); + } + + private structuredItemKey(entry: ConversationUiMessageItem, path: string): string { if (entry.tool && entry.tool.id) { return `${path}-tool-${entry.tool.id}`; } @@ -1085,9 +1122,9 @@ export struct ChatMessageBubble { return Math.abs(hash).toString(36); } - private activityGroupKey(items: ChatMessageItemResponse[], path: string): string { + private activityGroupKey(items: ConversationUiMessageItem[], path: string): string { const parts: string[] = []; - items.forEach((entry: ChatMessageItemResponse, index: number) => { + items.forEach((entry: ConversationUiMessageItem, index: number) => { const content = entry.content || ''; const tool = entry.tool; parts.push([ @@ -1100,38 +1137,38 @@ export struct ChatMessageBubble { return `${path}-${items.length}-${this.stableTextHash(parts.join('|'))}`; } - private normalizedToolName(tool: RemoteToolStatusResponse): string { + private normalizedToolName(tool: ConversationUiToolStatus): string { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } - private isCompletedTool(tool: RemoteToolStatusResponse): boolean { + private isCompletedTool(tool: ConversationUiToolStatus): boolean { const normalized = (tool.status || '').toLowerCase(); return normalized === 'completed' || normalized === 'done' || normalized === 'success'; } - private isCancelledTool(tool: RemoteToolStatusResponse): boolean { + private isCancelledTool(tool: ConversationUiToolStatus): boolean { const normalized = (tool.status || '').toLowerCase(); return normalized === 'cancelled' || normalized === 'canceled'; } - private isRunningTool(tool: RemoteToolStatusResponse): boolean { + private isRunningTool(tool: ConversationUiToolStatus): boolean { const normalized = (tool.status || '').toLowerCase(); return normalized === 'running' || normalized === 'active'; } - private isPendingTool(tool: RemoteToolStatusResponse): boolean { + private isPendingTool(tool: ConversationUiToolStatus): boolean { const normalized = (tool.status || '').toLowerCase(); return normalized === 'pending' || normalized === 'queued' || normalized === 'pending_confirmation' || normalized === 'needs_confirmation'; } - private hasToolError(tool: RemoteToolStatusResponse): boolean { + private hasToolError(tool: ConversationUiToolStatus): boolean { const normalized = (tool.status || '').toLowerCase(); return normalized === 'failed' || normalized === 'error' || normalized === 'timeout' || (tool.error_preview || '').length > 0; } - private isQuestionTool(tool: RemoteToolStatusResponse): boolean { + private isQuestionTool(tool: ConversationUiToolStatus): boolean { const normalized = this.normalizedToolName(tool); return (tool.name || '') === 'AskUserQuestion' || normalized === 'askuserquestion' || normalized === 'ask_user_question'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 54035420c2..9f379a06a0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -1,4 +1,4 @@ -import { ChatMessage, ChatMessageItemResponse, RemoteQuestionAnswerPayload } from '../../model/RemoteModels'; +import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, toConversationUiMessage } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ChatSurface } from './ChatSurface'; @@ -24,8 +24,8 @@ export struct ChatTimeline { (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - @Event onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = - (_toolId: string, _answers: RemoteQuestionAnswerPayload) => {}; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; @@ -119,7 +119,7 @@ export struct ChatTimeline { .onAppear(() => { this.logRenderedItem(item); }) - } else if (item.message && this.shouldRenderMessage(item.message)) { + } else if (item.message && this.shouldRenderMessage(toConversationUiMessage(item.message))) { ListItem() { this.MessageBubble(item) } @@ -137,7 +137,7 @@ export struct ChatTimeline { @Builder MessageBubble(item: ChatTimelineItem) { ChatMessageBubble({ - item: item.message as ChatMessage, + item: toConversationUiMessage(item.message!), isStreaming: item.isStreaming, isFinalizing: item.isFinalizing, isBusy: this.isBusy, @@ -153,7 +153,7 @@ export struct ChatTimeline { onCancelTool: (toolId: string) => { this.onCancelTool(toolId); }, - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => { + onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { this.onAnswerQuestion(toolId, answers); }, onCopyMessage: (text: string) => { @@ -168,7 +168,7 @@ export struct ChatTimeline { }) } - private shouldRenderMessage(item: ChatMessage): boolean { + private shouldRenderMessage(item: ConversationUiMessage): boolean { return item.role === 'user' || this.visibleMessageText(item).length > 0 || (item.thinking || '').trim().length > 0 || @@ -179,8 +179,8 @@ export struct ChatTimeline { item.status === 'failed'; } - private hasRenderableItems(items: ChatMessageItemResponse[]): boolean { - return items.some((entry: ChatMessageItemResponse) => { + private hasRenderableItems(items: ConversationUiMessageItem[]): boolean { + return items.some((entry: ConversationUiMessageItem) => { if ((entry.content || '').trim().length > 0 || !!entry.tool) { return true; } @@ -188,7 +188,7 @@ export struct ChatTimeline { }); } - private visibleMessageText(item: ChatMessage): string { + private visibleMessageText(item: ConversationUiMessage): string { const text = item.text.trim(); if (text === '(空消息)' || text === '(empty message)') { return ''; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 464562b4d2..284322c714 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -1,24 +1,37 @@ -import { SelectedImageAttachment } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatComposerPolicy } from '../../services/ChatComposerPolicy'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { ChatSurface } from './ChatSurface'; +import { ConversationUiSelectedImage } from './ConversationUiModels'; import { CARD, GREEN, INK, MUTED } from './Theme'; -@Component +@ComponentV2 export struct ComposerBar { - @Prop capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; - @Prop chatInput: string = ''; - @Link showQuickActions: boolean; - @Prop selectedImages: SelectedImageAttachment[] = []; - @Prop isBusy: boolean = false; - @Prop connectionState: string = 'connected'; - @Prop isVoiceListening: boolean = false; - onPickImages: () => void = () => {}; - onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; - onSend: () => void = () => {}; - onVoiceInput: () => void = () => {}; - onChatInputChange: (value: string) => void = (_value: string) => {}; + @Param capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + @Param chatInput: string = ''; + @Local inputText: string = ''; + @Param showQuickActions: boolean = false; + @Param selectedImages: ConversationUiSelectedImage[] = []; + @Param isBusy: boolean = false; + @Param connectionState: string = 'connected'; + @Param isVoiceListening: boolean = false; + @Event onToggleQuickActions: () => void = () => {}; + @Event onPickImages: () => void = () => {}; + @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; + @Event onSend: () => void = () => {}; + @Event onVoiceInput: () => void = () => {}; + @Event onChatInputChange: (value: string) => void = (_value: string) => {}; + + aboutToAppear(): void { + this.inputText = this.chatInput; + } + + @Monitor('chatInput') + onChatInputParamChanged(): void { + if (this.inputText !== this.chatInput) { + this.inputText = this.chatInput; + } + } build() { Column({ space: 8 }) { @@ -37,7 +50,7 @@ export struct ComposerBar { return; } if (this.capabilities.supportsAttachments) { - this.showQuickActions = !this.showQuickActions; + this.onToggleQuickActions(); return; } this.onPickImages(); @@ -64,7 +77,7 @@ export struct ComposerBar { if (this.isVoiceListening) { this.ListeningWave() } - TextInput({ placeholder: this.inputPlaceholder(), text: this.chatInput }) + TextInput({ placeholder: this.inputPlaceholder(), text: this.inputText }) .layoutWeight(1) .height(42) .fontSize(16) @@ -74,7 +87,13 @@ export struct ComposerBar { .borderRadius(20) .padding({ left: this.isVoiceListening ? 0 : 4, right: 4 }) .defaultFocus(false) - .onChange((value: string) => { + .onChange((value: string, previewText?: PreviewText) => { + // The first callback value excludes IME pre-edit text. Keep that text + // inside the native field until the IME commits it. + if (previewText && previewText.value.length > 0) { + return; + } + this.inputText = value; this.onChatInputChange(value); }) } @@ -182,9 +201,9 @@ export struct ComposerBar { SelectedImageStrip() { Scroll() { Row({ space: 10 }) { - ForEach(this.selectedImages, (image: SelectedImageAttachment) => { + ForEach(this.selectedImages, (image: ConversationUiSelectedImage) => { this.SelectedImageCard(image) - }, (image: SelectedImageAttachment) => image.id) + }, (image: ConversationUiSelectedImage) => image.id) } .padding({ right: 4 }) } @@ -194,7 +213,7 @@ export struct ComposerBar { } @Builder - SelectedImageCard(image: SelectedImageAttachment) { + SelectedImageCard(image: ConversationUiSelectedImage) { Stack({ alignContent: Alignment.TopEnd }) { Image(image.data_url) .width(64) @@ -220,7 +239,7 @@ export struct ComposerBar { private canSend(): boolean { return ChatComposerPolicy.canSend( - this.chatInput, + this.inputText, this.selectedImages.length, this.isBusy, this.capabilities.requiresRemoteConnection, @@ -229,11 +248,11 @@ export struct ComposerBar { } private canUseVoice(): boolean { - return ChatComposerPolicy.canUseVoice(this.chatInput, this.selectedImages.length, this.isBusy); + return ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); } private hasComposedContent(): boolean { - return this.chatInput.trim().length > 0 || this.selectedImages.length > 0; + return this.inputText.trim().length > 0 || this.selectedImages.length > 0; } private shouldShowAddButton(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 31c5aec78e..b23ea6c73e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -1,6 +1,7 @@ import { abilityAccessCtrl, Context, Permissions } from '@kit.AbilityKit'; import { customScan, scanBarcode, scanCore } from '@kit.ScanKit'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; const CONNECT_HERO: string = '#E6EDFF'; @@ -29,8 +30,11 @@ export struct ConnectView { @Prop desktopName: string = 'MacBook Pro'; @Prop desktopId: string = ''; @Prop deviceId: string = ''; + @Prop accountUserId: string = ''; + @Prop controlTargetDeviceId: string = ''; @Prop requiresAccountAuth: boolean = false; @Prop accountUsername: string = ''; + @Prop startWithScanner: boolean = true; onBack: () => void = () => {}; onConnect: (password?: string) => void = (_password?: string) => {}; onClearPairing: () => void = () => {}; @@ -40,6 +44,9 @@ export struct ConnectView { onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; onPasteRemoteUrl: () => void = () => {}; onScanRemoteUrl: () => void = () => {}; + cloudListDevices: () => Promise = async (): Promise => []; + cloudSelectDevice: (device: CloudAccountDevice) => Promise = + async (_device: CloudAccountDevice): Promise => {}; @State showHelp: boolean = false; @State pairingStep: string = 'intro'; @State showManualPairing: boolean = false; @@ -47,6 +54,19 @@ export struct ConnectView { @State accountPassword: string = ''; @State cameraPermissionReady: boolean = false; @State requestingCameraPermission: boolean = false; + @State accountDevices: CloudAccountDevice[] = []; + @State accountDevicesBusy: boolean = false; + @State accountDevicesError: string = ''; + @State switchingDeviceId: string = ''; + + aboutToAppear(): void { + if (this.isAccountAuthenticated()) { + this.pairingStep = 'account'; + this.refreshAccountDevices(); + } else if (this.startWithScanner && this.remoteUrl.trim().length === 0) { + this.pairingStep = 'scan'; + } + } aboutToDisappear(): void { this.stopInlineScan(); @@ -54,7 +74,9 @@ export struct ConnectView { build() { Stack() { - if (this.currentStep() === 'scan') { + if (this.currentStep() === 'account') { + this.AccountDeviceSelectionPage() + } else if (this.currentStep() === 'scan') { this.ScanPairingPage() } else { this.IntroPairingPage() @@ -68,6 +90,219 @@ export struct ConnectView { .backgroundColor(CARD) } + @Builder + AccountDeviceSelectionPage() { + Column() { + Row({ space: 16 }) { + Stack() { + this.BackGlyph() + } + .width(48) + .height(48) + .backgroundColor(SOFT) + .borderRadius(24) + .onClick(() => { + this.stopInlineScan(); + this.onBack(); + }) + Column({ space: 4 }) { + Text(RemoteI18n.t('connect.accountDevicesTitle')) + .fontSize(22) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .width('100%') + Text(RemoteI18n.t('connect.accountDevicesSubtitle')) + .fontSize(13) + .fontColor(MUTED) + .width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(92) + .padding({ left: 28, right: 28, top: 18 }) + .alignItems(VerticalAlign.Top) + + Scroll() { + Column({ space: 18 }) { + Text(RemoteI18n.t('connect.accountDevicesBody')) + .fontSize(14) + .lineHeight(21) + .fontColor(MUTED) + .width('100%') + + this.AccountDeviceList() + this.OrDivider() + + Button(RemoteI18n.t('connect.scanPairCodeAction')) + .width('100%') + .height(54) + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor(CARD) + .backgroundColor(ACCENT) + .borderRadius(8) + .onClick(() => { + this.openScannerAfterPermission(); + }) + + Button(RemoteI18n.t('connect.switchManualPair')) + .width('100%') + .height(52) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .backgroundColor(CARD) + .borderRadius(8) + .border({ width: 1.5, color: CONNECT_DISABLED }) + .onClick(() => { + this.stopInlineScan(); + this.showManualPairing = true; + this.onRemoteUrlInputVisibleChange(true); + }) + } + .width('100%') + .constraintSize({ minHeight: '100%' }) + .padding({ left: 28, right: 28, top: 10, bottom: 34 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor('#FAFAF9') + } + + @Builder + AccountDeviceList() { + Column({ space: 4 }) { + Row() { + Text(RemoteI18n.t('connect.availableDevices')) + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + Blank() + Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : + (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) + .fontSize(14) + .fontColor(this.accountDevicesBusy ? MUTED : + (this.accountDevicesError.length > 0 ? RED : ACCENT)) + .onClick(async () => { + await this.refreshAccountDevices(); + }) + } + .width('100%') + .height(38) + + if (this.accountDevicesBusy && this.accountDevices.length === 0) { + Column() { + this.AccountDeviceSkeletonRow() + this.AccountDeviceSkeletonRow() + } + .width('100%') + .height(120) + } else if (this.desktopDevices().length === 0) { + Row() { + Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) + .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') + } + .width('100%') + .height(120) + .alignItems(VerticalAlign.Center) + } else { + Scroll() { + Column() { + ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { + this.AccountConnectDeviceRow(device) + }, (device: CloudAccountDevice): string => + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + } + .width('100%') + } + .width('100%') + .height(120) + .scrollBar(BarState.Off) + } + + } + .width('100%') + .height(174) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(CARD) + .borderRadius(8) + .border({ width: 1, color: LINE }) + } + + @Builder + AccountDeviceSkeletonRow() { + Row({ space: 12 }) { + Text('') + .width(26) + .height(22) + .backgroundColor('#ECECE9') + .borderRadius(5) + Column({ space: 7 }) { + Text('') + .width('58%') + .height(12) + .backgroundColor('#ECECE9') + .borderRadius(4) + Text('') + .width(52) + .height(9) + .backgroundColor('#F0F0ED') + .borderRadius(4) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(60) + .padding({ left: 4, right: 4 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + AccountConnectDeviceRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + Image($r('app.media.remote_ref_device')) + .width(26).height(24).objectFit(ImageFit.Contain).opacity(device.online ? 0.68 : 0.38) + Column({ space: 3 }) { + Text(device.deviceName || device.deviceId) + .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountDeviceStatus(device)) + .fontSize(13).fontColor(device.online ? GREEN : MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (device.online) { + Image($r('app.media.settings_chevron_right')) + .width(8).height(12).objectFit(ImageFit.Contain).opacity(0.44) + } + } + .width('100%') + .height(60) + .padding({ left: 4, right: 4 }) + .alignItems(VerticalAlign.Center) + .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) + .onClick(async () => { + if (!this.canSelectAccountDevice(device)) return; + this.switchingDeviceId = device.deviceId; + this.accountDevicesError = ''; + try { + await this.cloudSelectDevice(device); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } finally { + this.switchingDeviceId = ''; + } + }) + } + @Builder IntroPairingPage() { Column() { @@ -229,21 +464,18 @@ export struct ConnectView { @Builder BackButton() { - Text('‹') + Stack() { + this.BackGlyph() + } .width(48) .height(48) - .fontSize(38) - .fontColor('#0D1A33') - .fontWeight(FontWeight.Medium) - .textAlign(TextAlign.Center) - .backgroundColor('#EEF4FF') + .backgroundColor(SOFT) .borderRadius(24) - .border({ width: 1, color: '#FFFFFF' }) - .position({ x: 36, y: 38 }) + .position({ x: 28, y: 18 }) .onClick(() => { if (this.currentStep() === 'scan' && this.remoteUrl.trim().length === 0 && !this.showManualPairing) { this.stopInlineScan(); - this.pairingStep = 'intro'; + this.pairingStep = this.isAccountAuthenticated() ? 'account' : 'intro'; return; } this.stopInlineScan(); @@ -251,6 +483,14 @@ export struct ConnectView { }) } + @Builder + BackGlyph() { + Image($r('app.media.remote_ref_back')) + .width(12) + .height(20) + .objectFit(ImageFit.Contain) + } + @Builder DesktopGlyph() { Stack() { @@ -357,6 +597,7 @@ export struct ConnectView { .onClick(() => { this.stopInlineScan(); this.showManualPairing = false; + this.resumeInlineScan(); }) Column({ space: 20 }) { @@ -421,6 +662,7 @@ export struct ConnectView { .onClick(() => { this.stopInlineScan(); this.showManualPairing = false; + this.resumeInlineScan(); }) Button(RemoteI18n.t('connect.pair')) .layoutWeight(1) @@ -832,6 +1074,9 @@ export struct ConnectView { } private currentStep(): string { + if (this.pairingStep === 'account' && this.isAccountAuthenticated()) { + return 'account'; + } if (this.pairingStep === 'scan' || this.remoteUrl.trim().length > 0 || this.isBusy || @@ -844,6 +1089,48 @@ export struct ConnectView { return 'intro'; } + private async refreshAccountDevices(): Promise { + if (!this.isAccountAuthenticated() || this.accountDevicesBusy) return; + this.accountDevicesBusy = true; + this.accountDevicesError = ''; + try { + this.accountDevices = await this.cloudListDevices(); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceLoadFailed'); + } finally { + this.accountDevicesBusy = false; + } + } + + private desktopDevices(): CloudAccountDevice[] { + return this.accountDevices.filter((device: CloudAccountDevice): boolean => + device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); + } + + private canSelectAccountDevice(device: CloudAccountDevice): boolean { + return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; + } + + private accountDeviceStatus(device: CloudAccountDevice): string { + if (device.deviceId === this.switchingDeviceId) { + return RemoteI18n.t('remote.settings.deviceConnecting'); + } + const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : + RemoteI18n.t('remote.settings.deviceOffline'); + if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { + return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; + } + if (device.deviceId === this.controlTargetDeviceId) { + return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; + } + return presence; + } + + private isAccountAuthenticated(): boolean { + return this.accountUserId.trim().length > 0; + } + private shouldShowStatusStrip(): boolean { return this.remoteUrl.trim().length > 0 || this.isBusy || @@ -946,6 +1233,18 @@ export struct ConnectView { }); } + private resumeInlineScan(): void { + if (this.remoteUrl.trim().length > 0 || this.pairingStep !== 'scan') { + return; + } + this.scanCompleted = false; + setTimeout(() => { + if (!this.showManualPairing) { + void this.startInlineScan(); + } + }, 80); + } + private async ensureCameraPermission(): Promise { const atManager = abilityAccessCtrl.createAtManager(); const context = this.permissionRequestContext(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets new file mode 100644 index 0000000000..71d1efcdd0 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets @@ -0,0 +1,71 @@ +import { ConversationUiQuestionAnswer } from './ConversationUiModels'; + +export enum ConversationIntentType { + OpenSidebar = 'open_sidebar', + Back = 'back', + NewSession = 'new_session', + TogglePin = 'toggle_pin', + Archive = 'archive', + Delete = 'delete', + ShowUploadedFiles = 'show_uploaded_files', + Stop = 'stop', + LoadOlder = 'load_older', + ApproveTool = 'approve_tool', + RejectTool = 'reject_tool', + CancelTool = 'cancel_tool', + AnswerQuestion = 'answer_question', + RenameSession = 'rename_session', + CopyMessage = 'copy_message', + RetryMessage = 'retry_message', + SelectModel = 'select_model', + PickImages = 'pick_images', + RemoveImage = 'remove_image', + DownloadFile = 'download_file', + Send = 'send', + VoiceInput = 'voice_input', + ChatInputChanged = 'chat_input_changed' +} + +export class ConversationIntent { + readonly type: ConversationIntentType; + readonly value: string; + readonly toolId: string; + readonly updatedInput?: Object; + readonly answers?: ConversationUiQuestionAnswer; + + constructor( + type: ConversationIntentType, + value: string = '', + toolId: string = '', + updatedInput?: Object, + answers?: ConversationUiQuestionAnswer + ) { + this.type = type; + this.value = value; + this.toolId = toolId; + this.updatedInput = updatedInput; + this.answers = answers; + } +} + +export class ConversationIntents { + static simple(type: ConversationIntentType): ConversationIntent { + return new ConversationIntent(type); + } + + static value(type: ConversationIntentType, value: string): ConversationIntent { + return new ConversationIntent(type, value); + } + + static approveTool(toolId: string, updatedInput?: Object): ConversationIntent { + return new ConversationIntent(ConversationIntentType.ApproveTool, '', toolId, updatedInput); + } + + static tool(type: ConversationIntentType, toolId: string): ConversationIntent { + return new ConversationIntent(type, '', toolId); + } + + static answerQuestion(toolId: string, answers: ConversationUiQuestionAnswer): ConversationIntent { + return new ConversationIntent(ConversationIntentType.AnswerQuestion, '', toolId, undefined, answers); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets new file mode 100644 index 0000000000..ff9702e1f3 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets @@ -0,0 +1,212 @@ +// Presentation-owned structures. Protocol DTOs are mapped at the boundary and +// never imported by leaf rendering components. +import { + ChatMessage, + ChatMessageItemResponse, + ImageAttachment, + RemoteModelCatalog, + RemoteModelConfig, + RemoteQuestionAnswerPayload, + SelectedImageAttachment, + SessionSummary, + RemoteToolStatusResponse +} from '../../model/RemoteModels'; + +export interface ConversationUiSession { + sessionId: string; + title: string; + workspacePath: string; + agentType: string; + initialTurnId?: string; +} + +export interface ConversationUiModel { + id: string; + name: string; + provider: string; + base_url: string; + model_name: string; + context_window?: number; + enabled: boolean; + capabilities: string[]; + enable_thinking_process?: boolean; + reasoning_mode?: string; + reasoning_effort?: string; +} + +export interface ConversationUiDefaultModels { + primary?: string; + fast?: string; + search?: string; + image_understanding?: string; +} + +export interface ConversationUiModelCatalog { + version: number; + models: ConversationUiModel[]; + default_models: ConversationUiDefaultModels; + session_model_id?: string; +} +export interface ConversationUiToolStatus { + id?: string; + name?: string; + status?: string; + duration_ms?: number; + start_ms?: number; + input_preview?: string; + tool_input?: Object; + stdout?: string; + stderr?: string; + tool_output?: Object; + result_preview?: string; + error_preview?: string; + exit_code?: number; +} + +export interface ConversationUiMessageItem { + type?: string; + content?: string; + tool?: ConversationUiToolStatus; + is_subagent?: boolean; + subItems?: ConversationUiMessageItem[]; +} + +export interface ConversationUiImage { + name: string; + data_url: string; +} + +export interface ConversationUiSelectedImage extends ConversationUiImage { + id: string; + uri: string; + mime_type: string; + size: number; + original_size: number; + compressed: boolean; +} + +export interface ConversationUiMessage { + id: string; + role: string; + text: string; + status: string; + renderVersion?: number; + turnId?: string; + detail?: string; + timestamp?: string; + thinking?: string; + tools?: ConversationUiToolStatus[]; + items?: ConversationUiMessageItem[]; + images?: ConversationUiImage[]; +} + +export interface ConversationUiQuestionAnswer { + answer: string; + '0': string; +} + +export function toConversationUiSession(source: SessionSummary): ConversationUiSession { + return { + sessionId: source.sessionId, + title: source.title, + workspacePath: source.workspacePath, + agentType: source.agentType, + initialTurnId: source.initialTurnId + }; +} + +export function toConversationUiModel(source: RemoteModelConfig): ConversationUiModel { + return { + id: source.id, + name: source.name, + provider: source.provider, + base_url: source.base_url, + model_name: source.model_name, + context_window: source.context_window, + enabled: source.enabled, + capabilities: source.capabilities.slice(), + enable_thinking_process: source.enable_thinking_process, + reasoning_mode: source.reasoning_mode, + reasoning_effort: source.reasoning_effort + }; +} + +export function toConversationUiModelCatalog(source: RemoteModelCatalog): ConversationUiModelCatalog { + return { + version: source.version, + models: source.models.map((model: RemoteModelConfig) => toConversationUiModel(model)), + default_models: { + primary: source.default_models.primary, + fast: source.default_models.fast, + search: source.default_models.search, + image_understanding: source.default_models.image_understanding + }, + session_model_id: source.session_model_id + }; +} + +export function toConversationUiSelectedImage(source: SelectedImageAttachment): ConversationUiSelectedImage { + return { + id: source.id, + name: source.name, + data_url: source.data_url, + uri: source.uri, + mime_type: source.mime_type, + size: source.size, + original_size: source.original_size, + compressed: source.compressed + }; +} + +export function toConversationUiToolStatus(source: RemoteToolStatusResponse): ConversationUiToolStatus { + return { + id: source.id, + name: source.name, + status: source.status, + duration_ms: source.duration_ms, + start_ms: source.start_ms, + input_preview: source.input_preview, + tool_input: source.tool_input, + stdout: source.stdout, + stderr: source.stderr, + tool_output: source.tool_output, + result_preview: source.result_preview, + error_preview: source.error_preview, + exit_code: source.exit_code + }; +} + +export function toConversationUiMessageItem(source: ChatMessageItemResponse): ConversationUiMessageItem { + return { + type: source.type, + content: source.content, + tool: source.tool ? toConversationUiToolStatus(source.tool) : undefined, + is_subagent: source.is_subagent, + subItems: source.subItems ? source.subItems.map((item: ChatMessageItemResponse) => toConversationUiMessageItem(item)) : undefined + }; +} + +export function toConversationUiImage(source: ImageAttachment): ConversationUiImage { + return { name: source.name, data_url: source.data_url }; +} + +export function toConversationUiMessage(source: ChatMessage): ConversationUiMessage { + return { + id: source.id, + role: source.role, + text: source.text, + status: source.status, + renderVersion: source.renderVersion, + turnId: source.turnId, + detail: source.detail, + timestamp: source.timestamp, + thinking: source.thinking, + tools: source.tools ? source.tools.map((tool: RemoteToolStatusResponse) => toConversationUiToolStatus(tool)) : undefined, + items: source.items ? source.items.map((item: ChatMessageItemResponse) => toConversationUiMessageItem(item)) : undefined, + images: source.images ? source.images.map((image: ImageAttachment) => toConversationUiImage(image)) : undefined + }; +} + +export function toRemoteQuestionAnswer(source: ConversationUiQuestionAnswer): RemoteQuestionAnswerPayload { + return { answer: source.answer, '0': source.answer }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 2b24977c59..2718845481 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -1,9 +1,3 @@ -import { - RemoteModelCatalog, - RemoteQuestionAnswerPayload, - SelectedImageAttachment, - SessionSummary -} from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; @@ -12,75 +6,82 @@ import { ChatSurface } from './ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; import { ChatTimeline } from './ChatTimeline'; import { ConversationViewContract } from './ConversationViewContract'; +import { + ConversationUiModelCatalog, + ConversationUiQuestionAnswer, + ConversationUiSelectedImage, + ConversationUiSession +} from './ConversationUiModels'; import { ComposerBar } from './ComposerBar'; import { GeneralChatHeader } from './GeneralChatHeader'; import { RemoteChatHeader } from './RemoteChatHeader'; import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED } from './Theme'; -@Component +@ComponentV2 export struct ConversationView { - @Prop surface: ChatSurface = ChatSurface.Remote; - @Prop activeSession: SessionSummary = { + @Param surface: ChatSurface = ChatSurface.Remote; + @Param activeSession: ConversationUiSession = { sessionId: '', title: '', workspacePath: '', agentType: 'code' }; - @Prop desktopName: string = ''; - @Prop workspaceBranch: string = ''; - @Prop statusText: string = ''; - @Prop inlineStatusText: string = ''; - @Prop connectionState: string = 'connected'; - @Prop composerCapabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; - @Prop isBusy: boolean = false; - @Prop canStop: boolean = false; - @Prop hasMoreMessages: boolean = false; - @Prop timelineItems: ChatTimelineItem[] = []; - @Prop timelineRevision: number = 0; - @Prop showSuggestionsWhenEmpty: boolean = false; - @Prop supportsSearch: boolean = false; - @Prop supportsImages: boolean = false; - @Prop supportsFiles: boolean = false; - @Prop modelCatalog: RemoteModelCatalog = { + @Param desktopName: string = ''; + @Param workspaceBranch: string = ''; + @Param statusText: string = ''; + @Param inlineStatusText: string = ''; + @Param connectionState: string = 'connected'; + @Param composerCapabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + @Param isBusy: boolean = false; + @Param canStop: boolean = false; + @Param hasMoreMessages: boolean = false; + @Param timelineItems: ChatTimelineItem[] = []; + @Param timelineRevision: number = 0; + @Param showSuggestionsWhenEmpty: boolean = false; + @Param supportsSearch: boolean = false; + @Param supportsImages: boolean = false; + @Param supportsFiles: boolean = false; + @Param modelCatalog: ConversationUiModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedModelId: string = ''; - @Prop downloadingFilePath: string = ''; - @Prop downloadedFilePath: string = ''; - @Prop fileDownloadStatus: string = ''; - @Prop selectedImages: SelectedImageAttachment[] = []; - @Prop isVoiceListening: boolean = false; - @Prop chatInput: string = ''; - onOpenSidebar: () => void = () => {}; - onBack: () => void = () => {}; - onNewSession: () => void = () => {}; - @Prop isSessionPinned: boolean = false; - onTogglePinSession: () => void = () => {}; - onArchiveSession: () => void = () => {}; - onDeleteSession: () => void = () => {}; - onShowUploadedFiles: () => void = () => {}; - onStop: () => void = () => {}; - onLoadOlder: () => void = () => {}; - onApproveTool: (toolId: string, updatedInput?: Object) => void = + @Param selectedModelId: string = ''; + @Param downloadingFilePath: string = ''; + @Param downloadedFilePath: string = ''; + @Param fileDownloadStatus: string = ''; + @Param selectedImages: ConversationUiSelectedImage[] = []; + @Param isVoiceListening: boolean = false; + @Param chatInput: string = ''; + @Event onOpenSidebar: () => void = () => {}; + @Event onBack: () => void = () => {}; + @Event onNewSession: () => void = () => {}; + @Param isSessionPinned: boolean = false; + @Event onTogglePinSession: () => void = () => {}; + @Event onArchiveSession: () => void = () => {}; + @Event onDeleteSession: () => void = () => {}; + @Event onShowUploadedFiles: () => void = () => {}; + @Event onStop: () => void = () => {}; + @Event onLoadOlder: () => void = () => {}; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; - onRejectTool: (toolId: string) => void = (_toolId: string) => {}; - onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = - (_toolId: string, _answers: RemoteQuestionAnswerPayload) => {}; - onRenameSession: (title: string) => void = (_title: string) => {}; - onCopyMessage: (text: string) => void = (_text: string) => {}; - onRetryMessage: (text: string) => void = (_text: string) => {}; - onSelectModel: (modelId: string) => void = (_modelId: string) => {}; - onPickImages: () => void = () => {}; - onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; - onDownloadFile: (path: string) => void = (_path: string) => {}; - onSend: () => void = () => {}; - onVoiceInput: () => void = () => {}; - onChatInputChange: (value: string) => void = (_value: string) => {}; - @State showQuickActions: boolean = false; - @State showHeaderActions: boolean = false; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onRenameSession: (title: string) => void = (_title: string) => {}; + @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; + @Event onPickImages: () => void = () => {}; + @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; + @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + @Event onSend: () => void = () => {}; + @Event onVoiceInput: () => void = () => {}; + @Event onChatInputChange: (value: string) => void = (_value: string) => {}; + @Event onToggleQuickActions: () => void = () => {}; + @Local showQuickActions: boolean = false; + @Local showHeaderActions: boolean = false; build() { Stack() { @@ -202,7 +203,7 @@ export struct ConversationView { onCancelTool: (toolId: string) => { this.onCancelTool(toolId); }, - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => { + onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { this.onAnswerQuestion(toolId, answers); }, onCopyMessage: (text: string) => { @@ -256,7 +257,10 @@ export struct ConversationView { ComposerBar({ capabilities: this.composerCapabilities, chatInput: this.chatInput, - showQuickActions: $showQuickActions, + showQuickActions: this.showQuickActions, + onToggleQuickActions: () => { + this.showQuickActions = !this.showQuickActions; + }, selectedImages: this.selectedImages, isBusy: this.isBusy, connectionState: this.connectionState, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets new file mode 100644 index 0000000000..95e793d457 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -0,0 +1,73 @@ +import { ConversationViewState } from '../state/ConversationViewState'; +import { ConversationView } from './ConversationView'; +import { + ConversationIntent, + ConversationIntents, + ConversationIntentType +} from './ConversationIntent'; +import { ConversationUiQuestionAnswer } from './ConversationUiModels'; + +@ComponentV2 +export struct ConversationViewHost { + @Param viewState: ConversationViewState = new ConversationViewState(); + @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; + + build() { + ConversationView({ + surface: this.viewState.surface, + activeSession: this.viewState.activeSession, + desktopName: this.viewState.desktopName, + workspaceBranch: this.viewState.workspaceBranch, + statusText: this.viewState.statusText, + inlineStatusText: this.viewState.inlineStatusText, + connectionState: this.viewState.connectionState, + composerCapabilities: this.viewState.composerCapabilities, + isBusy: this.viewState.isBusy, + canStop: this.viewState.canStop, + hasMoreMessages: this.viewState.hasMoreMessages, + timelineItems: this.viewState.timelineItems, + timelineRevision: this.viewState.timelineRevision, + showSuggestionsWhenEmpty: this.viewState.showSuggestionsWhenEmpty, + supportsSearch: this.viewState.supportsSearch, + supportsImages: this.viewState.supportsImages, + supportsFiles: this.viewState.supportsFiles, + modelCatalog: this.viewState.modelCatalog, + selectedModelId: this.viewState.selectedModelId, + downloadingFilePath: this.viewState.downloadingFilePath, + downloadedFilePath: this.viewState.downloadedFilePath, + fileDownloadStatus: this.viewState.fileDownloadStatus, + selectedImages: this.viewState.selectedImages, + isVoiceListening: this.viewState.isVoiceListening, + chatInput: this.viewState.chatInput, + isSessionPinned: this.viewState.isSessionPinned, + onOpenSidebar: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.OpenSidebar)), + onBack: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Back)), + onNewSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.NewSession)), + onTogglePinSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.TogglePin)), + onArchiveSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Archive)), + onDeleteSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Delete)), + onShowUploadedFiles: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.ShowUploadedFiles)), + onStop: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Stop)), + onLoadOlder: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.LoadOlder)), + onApproveTool: (id: string, input?: Object) => this.dispatch(ConversationIntents.approveTool(id, input)), + onRejectTool: (id: string) => this.dispatch(ConversationIntents.tool(ConversationIntentType.RejectTool, id)), + onCancelTool: (id: string) => this.dispatch(ConversationIntents.tool(ConversationIntentType.CancelTool, id)), + onAnswerQuestion: (id: string, answers: ConversationUiQuestionAnswer) => + this.dispatch(ConversationIntents.answerQuestion(id, answers)), + onRenameSession: (title: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RenameSession, title)), + onCopyMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.CopyMessage, text)), + onRetryMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RetryMessage, text)), + onSelectModel: (id: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.SelectModel, id)), + onPickImages: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.PickImages)), + onRemoveImage: (id: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RemoveImage, id)), + onDownloadFile: (path: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.DownloadFile, path)), + onSend: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Send)), + onVoiceInput: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.VoiceInput)), + onChatInputChange: (value: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.ChatInputChanged, value)) + }) + } + + private dispatch(intent: ConversationIntent): void { + this.onIntent(intent); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index a8e11e61dc..ca1c70e5ce 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -1,32 +1,32 @@ -import { RemoteModelCatalog, RemoteModelConfig, SessionSummary } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiModel, ConversationUiModelCatalog, ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG } from './Theme'; -@Component +@ComponentV2 export struct RemoteChatHeader { - @Prop activeSession: SessionSummary = { + @Param activeSession: ConversationUiSession = { sessionId: '', title: '', workspacePath: '', agentType: 'code' }; - @Prop workspaceBranch: string = ''; - @Prop desktopName: string = ''; - @Prop canStop: boolean = false; - @Prop modelCatalog: RemoteModelCatalog = { + @Param workspaceBranch: string = ''; + @Param desktopName: string = ''; + @Param canStop: boolean = false; + @Param modelCatalog: ConversationUiModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedModelId: string = ''; - onBack: () => void = () => {}; - onNewSession: () => void = () => {}; - onStop: () => void = () => {}; - onRenameSession: (title: string) => void = (_title: string) => {}; - onSelectModel: (modelId: string) => void = (_modelId: string) => {}; - @State showTitleEditor: boolean = false; - @State showModelSelector: boolean = false; - @State renameTitle: string = ''; + @Param selectedModelId: string = ''; + @Event onBack: () => void = () => {}; + @Event onNewSession: () => void = () => {}; + @Event onStop: () => void = () => {}; + @Event onRenameSession: (title: string) => void = (_title: string) => {}; + @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; + @Local showTitleEditor: boolean = false; + @Local showModelSelector: boolean = false; + @Local renameTitle: string = ''; build() { Column() { @@ -205,11 +205,11 @@ export struct RemoteChatHeader { }) } List({ space: 8 }) { - ForEach(this.enabledModels(), (model: RemoteModelConfig) => { + ForEach(this.enabledModels(), (model: ConversationUiModel) => { ListItem() { this.ModelRow(model) } - }, (model: RemoteModelConfig) => model.id) + }, (model: ConversationUiModel) => model.id) } .width('100%') .height(this.modelListHeight()) @@ -223,7 +223,7 @@ export struct RemoteChatHeader { } @Builder - ModelRow(model: RemoteModelConfig) { + ModelRow(model: ConversationUiModel) { Row({ space: 10 }) { Text(this.selectedModelId === model.id ? '●' : '○') .fontSize(12) @@ -265,8 +265,8 @@ export struct RemoteChatHeader { return this.workspaceBranch.length > 0 ? `${brand} · ${this.workspaceBranch}` : brand; } - private enabledModels(): RemoteModelConfig[] { - return this.modelCatalog.models.filter((model: RemoteModelConfig) => model.enabled); + private enabledModels(): ConversationUiModel[] { + return this.modelCatalog.models.filter((model: ConversationUiModel) => model.enabled); } private modelListHeight(): number { @@ -287,15 +287,15 @@ export struct RemoteChatHeader { return RemoteI18n.t('chat.model'); } - private selectedModel(): RemoteModelConfig | undefined { + private selectedModel(): ConversationUiModel | undefined { const modelId = this.selectedModelId || this.modelCatalog.session_model_id || this.modelCatalog.default_models.primary || ''; if (modelId.length === 0) { return undefined; } - return this.modelCatalog.models.find((model: RemoteModelConfig) => model.id === modelId); + return this.modelCatalog.models.find((model: ConversationUiModel) => model.id === modelId); } - private primaryModelLabel(model: RemoteModelConfig): string { + private primaryModelLabel(model: ConversationUiModel): string { const modelName = this.cleanModelLabel(model.model_name || ''); if (this.isSpecificModelLabel(modelName)) { return modelName; @@ -311,7 +311,7 @@ export struct RemoteChatHeader { return RemoteI18n.t('chat.model'); } - private secondaryModelLabel(model: RemoteModelConfig): string { + private secondaryModelLabel(model: ConversationUiModel): string { const provider = this.cleanModelLabel(model.provider || ''); const name = this.cleanModelLabel(model.name || ''); const primary = this.primaryModelLabel(model); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 4f657d4ddf..211b8fcec1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -1,5 +1,8 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, GREEN, INK, LINE, MUTED } from './Theme'; +import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemotePermissionMode } from '../../model/RemoteModels'; const REMOTE_SETTINGS_BG: string = '#F4F4F7'; const REMOTE_SETTINGS_BLUE: string = '#0A84FF'; @@ -10,16 +13,50 @@ export struct RemoteControlSettingsSheet { @Prop desktopId: string = ''; @Prop userId: string = ''; @Prop accountUsername: string = ''; - @Prop authenticatedUserId: string = ''; + @Prop accountUserId: string = ''; @Prop deviceId: string = ''; + @Prop controlTargetDeviceId: string = ''; @Prop connectionState: string = 'idle'; @Prop statusText: string = ''; @Prop isBusy: boolean = false; + @Prop openProfileOnAppear: boolean = false; onClose: () => void = () => {}; onAddConnection: () => void = () => {}; + cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; + cloudSync: () => Promise = async (): Promise => '0'; + cloudLogout: () => Promise = async (): Promise => {}; + cloudListDevices: () => Promise = async (): Promise => []; + getPermissionMode: () => Promise = async (): Promise => 'ask'; + setPermissionMode: (mode: RemotePermissionMode) => Promise = + async (mode: RemotePermissionMode): Promise => mode; onDisconnect: () => void = () => {}; onReconnect: () => void = () => {}; @State showProfile: boolean = false; + @State loginRelayUrl: string = DEFAULT_CLOUD_RELAY_URL; + @State loginUsername: string = ''; + @State loginPassword: string = ''; + @State loginError: string = ''; + @State loginBusy: boolean = false; + @State cloudSyncBusy: boolean = false; + @State cloudSyncStatus: string = ''; + @State accountDevices: CloudAccountDevice[] = []; + @State accountDevicesBusy: boolean = false; + @State accountDevicesError: string = ''; + @State permissionMode: RemotePermissionMode = 'ask'; + @State permissionModeBusy: boolean = false; + @State permissionModeLoaded: boolean = false; + @State permissionModeError: string = ''; + @State confirmFullAccess: boolean = false; + + aboutToAppear(): void { + this.showProfile = this.openProfileOnAppear; + if (this.isAccountAuthenticated()) { + this.refreshAccountDevices(); + } + if (this.canManagePermissions()) { + this.refreshPermissionMode(); + } + } build() { Stack({ alignContent: Alignment.TopEnd }) { @@ -53,9 +90,10 @@ export struct RemoteControlSettingsSheet { this.ConnectionSectionHeader() this.ConnectionCard() + this.PermissionSectionHeader() + this.PermissionModeCard() } .width('100%') - .height('100%') .padding({ left: 18, right: 18, top: 20, bottom: 42 }) } .width('100%') @@ -187,6 +225,150 @@ export struct RemoteControlSettingsSheet { .borderRadius(28) } + @Builder + private PermissionSectionHeader() { + Row() { + Text(RemoteI18n.t('remote.permissions.title')) + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#929298') + Blank() + if (this.canManagePermissions()) { + Text(RemoteI18n.t('common.refresh')) + .fontSize(14) + .fontColor(this.permissionModeBusy ? MUTED : REMOTE_SETTINGS_BLUE) + .onClick(() => this.refreshPermissionMode()) + } + } + .width('100%') + .height(54) + .padding({ left: 18, right: 16, top: 12 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private PermissionModeCard() { + Column() { + Text(RemoteI18n.t('remote.permissions.scope')) + .width('100%') + .fontSize(13) + .lineHeight(19) + .fontColor(MUTED) + .padding({ left: 18, right: 18, top: 16, bottom: 8 }) + + this.PermissionModeRow( + 'ask', + RemoteI18n.t('remote.permissions.ask'), + RemoteI18n.t('remote.permissions.askDescription') + ) + Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 }) + this.PermissionModeRow( + 'auto', + RemoteI18n.t('remote.permissions.auto'), + RemoteI18n.t('remote.permissions.autoDescription') + ) + Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 }) + this.PermissionModeRow( + 'full_access', + RemoteI18n.t('remote.permissions.fullAccess'), + RemoteI18n.t('remote.permissions.fullAccessDescription') + ) + + Text(this.permissionStatusText()) + .width('100%') + .height(28) + .fontSize(12) + .fontColor(this.permissionModeError.length > 0 ? '#D04A3A' : MUTED) + .padding({ left: 18, right: 18, top: 4 }) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + if (this.confirmFullAccess) { + this.FullAccessConfirmation() + } + } + .width('100%') + .backgroundColor(CARD) + .borderRadius(28) + } + + @Builder + private PermissionModeRow(mode: RemotePermissionMode, label: string, description: string) { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.permissionModeLoaded && this.permissionMode === mode) { + Image($r('app.media.remote_actions_check')) + .width(20) + .height(20) + .objectFit(ImageFit.Contain) + } + } + .width(22) + .height(24) + Column({ space: 3 }) { + Text(label) + .width('100%') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(description) + .width('100%') + .fontSize(12) + .lineHeight(17) + .fontColor(MUTED) + .maxLines(2) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(72) + .padding({ left: 18, right: 18 }) + .opacity(this.canChangePermissionMode() ? 1 : 0.54) + .onClick(() => this.selectPermissionMode(mode)) + } + + @Builder + private FullAccessConfirmation() { + Column({ space: 10 }) { + Text(RemoteI18n.t('remote.permissions.fullAccessWarningTitle')) + .width('100%') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor('#B9382D') + Text(RemoteI18n.t('remote.permissions.fullAccessWarningBody')) + .width('100%') + .fontSize(13) + .lineHeight(19) + .fontColor(INK) + Row({ space: 10 }) { + Button(RemoteI18n.t('remote.permissions.cancel')) + .layoutWeight(1) + .height(42) + .fontSize(14) + .fontColor(INK) + .backgroundColor('#F0EFEC') + .borderRadius(21) + .onClick(() => { this.confirmFullAccess = false; }) + Button(RemoteI18n.t('remote.permissions.confirmFullAccess')) + .layoutWeight(1) + .height(42) + .fontSize(14) + .fontColor(CARD) + .backgroundColor('#C63E3E') + .borderRadius(21) + .onClick(() => this.applyPermissionMode('full_access')) + } + .width('100%') + } + .width('100%') + .padding({ left: 16, right: 16, top: 14, bottom: 16 }) + .backgroundColor('#FFF3F1') + .border({ width: 1, color: '#F0B3AA' }) + .borderRadius(18) + .margin({ left: 12, right: 12, bottom: 14 }) + } + @Builder private ProfilePage() { Scroll() { @@ -242,6 +424,10 @@ export struct RemoteControlSettingsSheet { .borderRadius(28) .margin({ bottom: 24 }) + this.AccountStatusCard() + + this.DeviceManagementCard() + Text(RemoteI18n.t('remote.settings.profileDetails')) .fontSize(18) .fontWeight(FontWeight.Bold) @@ -253,15 +439,12 @@ export struct RemoteControlSettingsSheet { this.ProfileDetailRow(RemoteI18n.t('remote.settings.userId'), this.profileIdentifier()) Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 }) this.ProfileDetailRow(RemoteI18n.t('remote.settings.deviceId'), this.deviceId || '-') - Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 }) - this.ProfileDetailRow(RemoteI18n.t('remote.settings.connectedDesktop'), this.connectionTitle()) } .width('100%') .backgroundColor(CARD) .borderRadius(28) } .width('100%') - .height('100%') .padding({ left: 18, right: 18, top: 20, bottom: 42 }) } .width('100%') @@ -269,6 +452,204 @@ export struct RemoteControlSettingsSheet { .scrollBar(BarState.Off) } + @Builder + private AccountStatusCard() { + Column({ space: 10 }) { + Row({ space: 10 }) { + Text(RemoteI18n.t('remote.settings.account')) + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + Blank() + Text(this.isAccountAuthenticated() ? RemoteI18n.t('remote.settings.accountSignedIn') : + RemoteI18n.t('remote.settings.accountNotSignedIn')) + .fontSize(14) + .fontColor(this.isAccountAuthenticated() ? GREEN : MUTED) + } + .width('100%') + + Text(this.isAccountAuthenticated() ? + RemoteI18n.f('remote.settings.accountSignedInBody', this.accountUsername || this.accountUserId) : + RemoteI18n.t('remote.settings.accountNotSignedInBody')) + .fontSize(14) + .lineHeight(20) + .fontColor(MUTED) + .width('100%') + + if (!this.isAccountAuthenticated()) { + TextInput({ placeholder: RemoteI18n.t('remote.settings.relayUrlPlaceholder'), text: this.loginRelayUrl }) + .height(48) + .fontSize(15) + .backgroundColor('#ECEBE8') + .borderRadius(24) + .padding({ left: 16, right: 16 }) + .onChange((value: string) => { this.loginRelayUrl = value; }) + TextInput({ placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), text: this.loginUsername }) + .height(48) + .fontSize(15) + .backgroundColor('#ECEBE8') + .borderRadius(24) + .padding({ left: 16, right: 16 }) + .onChange((value: string) => { this.loginUsername = value; }) + TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.loginPassword }) + .height(48) + .fontSize(15) + .backgroundColor('#ECEBE8') + .borderRadius(24) + .padding({ left: 16, right: 16 }) + .type(InputType.Password) + .onChange((value: string) => { this.loginPassword = value; }) + if (this.loginError.length > 0) { + Text(this.loginError).fontSize(13).fontColor('#D04A3A').width('100%') + } + Button(this.loginBusy ? RemoteI18n.t('remote.settings.accountSigningIn') : RemoteI18n.t('remote.settings.accountSignIn')) + .height(44) + .width('100%') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(CARD) + .backgroundColor(REMOTE_SETTINGS_BLUE) + .borderRadius(22) + .enabled(!this.loginBusy && this.loginRelayUrl.trim().length > 0 && this.loginUsername.trim().length > 0 && this.loginPassword.length > 0) + .onClick(async () => { + this.loginBusy = true; + this.loginError = ''; + try { + await this.cloudLogin(this.loginRelayUrl, this.loginUsername, this.loginPassword); + this.loginPassword = ''; + await this.refreshAccountDevices(); + } catch (err) { + this.loginError = err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountLoginFailed'); + } finally { + this.loginBusy = false; + } + }) + } else { + Button(RemoteI18n.t('remote.settings.accountSync')) + .height(42) + .width('100%') + .fontSize(15) + .fontColor(REMOTE_SETTINGS_BLUE) + .backgroundColor('#E8F2FF') + .borderRadius(21) + .enabled(!this.cloudSyncBusy) + .onClick(async () => { + this.cloudSyncBusy = true; + this.cloudSyncStatus = ''; + try { + const count = await this.cloudSync(); + this.cloudSyncStatus = RemoteI18n.f('remote.settings.accountSyncSuccess', count); + } catch (err) { + this.cloudSyncStatus = err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed'); + } finally { + this.cloudSyncBusy = false; + } + }) + Button(RemoteI18n.t('remote.settings.accountLogout')) + .height(42) + .width('100%') + .fontSize(15) + .fontColor('#D04A3A') + .backgroundColor('#FFF0EE') + .borderRadius(21) + .onClick(async () => { + await this.cloudLogout(); + this.cloudSyncStatus = ''; + }) + if (this.cloudSyncStatus.length > 0) { + Text(this.cloudSyncStatus).fontSize(13).fontColor(MUTED).width('100%') + } + } + } + .width('100%') + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(24) + .margin({ bottom: 24 }) + } + + @Builder + private DeviceManagementCard() { + Column({ space: 8 }) { + Row() { + Text(RemoteI18n.t('remote.settings.deviceManagement')) + .fontSize(17).fontWeight(FontWeight.Bold).fontColor(INK) + Blank() + Text(this.accountDevicesBusy ? RemoteI18n.t('remote.settings.deviceLoading') : + RemoteI18n.t('remote.settings.deviceRefresh')) + .fontSize(13).fontColor(this.accountDevicesBusy ? MUTED : REMOTE_SETTINGS_BLUE) + .onClick(async () => { + await this.refreshAccountDevices(); + }) + }.width('100%') + if (!this.isAccountAuthenticated()) { + Text(RemoteI18n.t('remote.settings.deviceLoginRequired')) + .fontSize(13).lineHeight(18).fontColor(MUTED).width('100%') + } else if (this.accountDevicesBusy && this.accountDevices.length === 0) { + Text(RemoteI18n.t('remote.settings.deviceLoading')) + .fontSize(13).fontColor(MUTED).width('100%') + } else if (this.desktopDevices().length === 0) { + Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) + .fontSize(13).lineHeight(18).fontColor(MUTED).width('100%') + } else { + ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { + this.AccountDeviceRow(device) + }, (device: CloudAccountDevice): string => + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + } + } + .width('100%').padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD).borderRadius(24).margin({ bottom: 24 }) + } + + @Builder + private AccountDeviceRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + Image($r('app.media.remote_ref_device')) + .width(24).height(22).objectFit(ImageFit.Contain).opacity(0.58) + Column({ space: 2 }) { + Text(device.deviceName || device.deviceId) + .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.deviceStatus(device)) + .fontSize(13).fontColor(device.online ? GREEN : MUTED) + }.layoutWeight(1).alignItems(HorizontalAlign.Start) + } + .width('100%').height(54).alignItems(VerticalAlign.Center) + .opacity(device.online ? 1 : 0.68) + } + + private async refreshAccountDevices(): Promise { + if (!this.isAccountAuthenticated() || this.accountDevicesBusy) return; + this.accountDevicesBusy = true; + this.accountDevicesError = ''; + try { + this.accountDevices = await this.cloudListDevices(); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed'); + } finally { + this.accountDevicesBusy = false; + } + } + + private desktopDevices(): CloudAccountDevice[] { + return this.accountDevices.filter((device: CloudAccountDevice): boolean => + device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); + } + + private deviceStatus(device: CloudAccountDevice): string { + if (device.deviceId === this.deviceId) return RemoteI18n.t('remote.settings.deviceCurrent'); + const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : + RemoteI18n.t('remote.settings.deviceOffline'); + if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { + return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; + } + if (device.deviceId === this.controlTargetDeviceId) { + return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; + } + return presence; + } + @Builder private ProfileDetailRow(label: string, value: string) { Row({ space: 16 }) { @@ -313,6 +694,72 @@ export struct RemoteControlSettingsSheet { this.connectionState === 'pairing' || this.connectionState === 'parsing'; } + private canManagePermissions(): boolean { + return this.connectionState === 'connected'; + } + + private canChangePermissionMode(): boolean { + return this.canManagePermissions() && !this.permissionModeBusy; + } + + private async refreshPermissionMode(): Promise { + if (!this.canManagePermissions() || this.permissionModeBusy) { + return; + } + this.permissionModeBusy = true; + this.permissionModeError = ''; + try { + this.permissionMode = await this.getPermissionMode(); + this.permissionModeLoaded = true; + } catch (err) { + this.permissionModeError = err instanceof Error ? err.message : + RemoteI18n.t('remote.permissions.loadFailed'); + } finally { + this.permissionModeBusy = false; + } + } + + private selectPermissionMode(mode: RemotePermissionMode): void { + if (!this.canChangePermissionMode() || + (this.permissionModeLoaded && this.permissionMode === mode)) { + return; + } + if (mode === 'full_access') { + this.confirmFullAccess = true; + return; + } + this.confirmFullAccess = false; + this.applyPermissionMode(mode); + } + + private async applyPermissionMode(mode: RemotePermissionMode): Promise { + if (!this.canChangePermissionMode()) { + return; + } + this.permissionModeBusy = true; + this.permissionModeError = ''; + try { + this.permissionMode = await this.setPermissionMode(mode); + this.permissionModeLoaded = true; + this.confirmFullAccess = false; + } catch (err) { + this.permissionModeError = err instanceof Error ? err.message : + RemoteI18n.t('remote.permissions.saveFailed'); + } finally { + this.permissionModeBusy = false; + } + } + + private permissionStatusText(): string { + if (!this.canManagePermissions()) { + return RemoteI18n.t('remote.permissions.connectionRequired'); + } + if (this.permissionModeBusy) { + return RemoteI18n.t('remote.permissions.loading'); + } + return this.permissionModeError; + } + private connectionTitle(): string { return this.desktopName || this.desktopId || RemoteI18n.t('remote.settings.noDesktop'); } @@ -332,6 +779,10 @@ export struct RemoteControlSettingsSheet { } private profileIdentifier(): string { - return this.authenticatedUserId || this.userId || this.deviceId || '-'; + return this.accountUserId || this.userId || this.deviceId || '-'; + } + + private isAccountAuthenticated(): boolean { + return this.accountUserId.trim().length > 0; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets new file mode 100644 index 0000000000..a278d258af --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets @@ -0,0 +1,360 @@ +import { KeyboardAvoidMode } from '@kit.ArkUI'; +import { RecentWorkspaceEntry } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; + +@ComponentV2 +export struct RemoteCreateSessionView { + @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Event onBack: () => void = () => {}; + @Event onToggleDeviceMenu: () => void = () => {}; + @Event onToggleWorkspaceMenu: () => void = () => {}; + @Event onSelectDevice: (device: CloudAccountDevice) => void = (_device: CloudAccountDevice) => {}; + @Event onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => void = (_workspace?: RecentWorkspaceEntry) => {}; + @Event onDraftChange: (value: string) => void = (_value: string) => {}; + @Event onSend: () => void = () => {}; + private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; + + aboutToAppear(): void { + this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); + this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); + setTimeout(() => this.keepComposerFocused(), 180); + } + + aboutToDisappear(): void { + this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); + } + + build() { + Column() { + Column() { + Row() { + Button() { + Image($r('app.media.remote_ref_back')) + .width(16) + .height(25) + .objectFit(ImageFit.Contain) + } + .width(48) + .height(48) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(CARD) + .borderRadius(24) + .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) + .onClick(() => this.onBack()) + } + .width('100%') + .height(78) + .padding({ left: 18, top: 14 }) + .alignItems(VerticalAlign.Top) + Column() + .width('100%') + .layoutWeight(1) + .onClick(() => { + this.state.closeMenu(); + this.keepComposerFocused(); + }) + } + .width('100%') + .layoutWeight(1) + this.ContextControls() + this.Composer() + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + ContextControls() { + Column({ space: 2 }) { + this.ContextRow( + 'device', + this.state.selectedDeviceName || RemoteI18n.t('remote.create.noDevice'), + this.state.isLoadingDevices, + () => this.onToggleDeviceMenu() + ) + this.ContextRow( + 'workspace', + this.state.selectedWorkspaceName || RemoteI18n.t('remote.create.chat'), + this.state.isLoadingWorkspaces, + () => this.onToggleWorkspaceMenu() + ) + } + .width('100%') + .padding({ left: 28, right: 28, bottom: 4 }) + } + + @Builder + ContextRow(kind: string, title: string, loading: boolean, onClick: () => void) { + Row({ space: 13 }) { + Image(kind === 'device' ? $r('app.media.remote_create_device') : + (this.state.selectedWorkspacePath.length > 0 ? $r('app.media.remote_create_folder') : $r('app.media.remote_create_chat'))) + .width(26) + .height(26) + .objectFit(ImageFit.Contain) + .opacity(loading ? 0.42 : 1) + Text(loading ? RemoteI18n.t('common.loading') : title) + .constraintSize({ maxWidth: '74%' }) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(loading ? MUTED : INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Image($r('app.media.remote_create_switch')) + .width(22) + .height(30) + .objectFit(ImageFit.Contain) + .opacity(loading ? 0.42 : 1) + Blank() + .layoutWeight(1) + } + .width('100%') + .height(48) + .padding({ left: 8, right: 10 }) + .borderRadius(12) + .bindPopup(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { + builder: () => { + if (kind === 'device') { + this.DeviceMenu() + } else { + this.WorkspaceMenu() + } + }, + placement: Placement.Top, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + if (!event.isVisible) { + this.state.closeMenu(); + } + } + }) + .onClick(() => { + onClick(); + this.keepComposerFocused(); + }) + } + + @Builder + DeviceMenu() { + Column() { + if (this.state.devices.length === 0 && this.state.isLoadingDevices) { + this.MenuMessage(RemoteI18n.t('common.loading')) + } else if (this.state.devices.length === 0) { + this.MenuMessage(RemoteI18n.t('remote.create.noOnlineDevice')) + } else { + ForEach(this.state.devices, (device: CloudAccountDevice) => { + this.DeviceMenuRow(device) + }, (device: CloudAccountDevice) => device.deviceId) + } + } + .width(340) + .padding({ top: 8, bottom: 8 }) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + .shadow({ radius: 26, color: '#1A000000', offsetY: 10 }) + } + + @Builder + DeviceMenuRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + this.SelectionMark(device.deviceId === this.state.selectedDeviceId) + Image($r('app.media.remote_create_device')) + .width(27) + .height(27) + .objectFit(ImageFit.Contain) + Text(device.deviceName) + .layoutWeight(1) + .fontSize(16) + .fontColor(INK) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(58) + .padding({ left: 16, right: 18, top: 8, bottom: 8 }) + .onClick(() => { + this.onSelectDevice(device); + this.keepComposerFocused(); + }) + } + + @Builder + WorkspaceMenu() { + Column() { + Row({ space: 12 }) { + this.SelectionMark(this.state.selectedWorkspacePath.length === 0) + Image($r('app.media.remote_create_chat')) + .width(27) + .height(27) + .objectFit(ImageFit.Contain) + Text(RemoteI18n.t('remote.create.chat')) + .layoutWeight(1) + .fontSize(16) + .fontColor(INK) + } + .width('100%') + .height(58) + .padding({ left: 16, right: 18 }) + .onClick(() => { + this.onSelectWorkspace(undefined); + this.keepComposerFocused(); + }) + + Divider().color(LINE).margin({ left: 58, right: 18 }) + + if (this.state.workspaces.length === 0 && this.state.isLoadingWorkspaces) { + this.MenuMessage(RemoteI18n.t('common.loading')) + } else if (this.state.workspaces.length === 0) { + this.MenuMessage(RemoteI18n.t('home.noRecentWorkspaces')) + } else { + Scroll() { + Column() { + ForEach(this.state.workspaces, (workspace: RecentWorkspaceEntry) => { + this.WorkspaceMenuRow(workspace) + }, (workspace: RecentWorkspaceEntry) => workspace.path) + } + } + .width('100%') + .constraintSize({ maxHeight: 190 }) + .scrollBar(BarState.Off) + } + } + .width(340) + .padding({ top: 8, bottom: 8 }) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + .shadow({ radius: 26, color: '#1A000000', offsetY: 10 }) + } + + @Builder + WorkspaceMenuRow(workspace: RecentWorkspaceEntry) { + Row({ space: 12 }) { + this.SelectionMark(workspace.path === this.state.selectedWorkspacePath) + Image($r('app.media.remote_create_folder')) + .width(27) + .height(27) + .objectFit(ImageFit.Contain) + Column({ space: 2 }) { + Text(workspace.name) + .width('100%') + .fontSize(16) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(workspace.path) + .width('100%') + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(64) + .padding({ left: 16, right: 18 }) + .onClick(() => { + this.onSelectWorkspace(workspace); + this.keepComposerFocused(); + }) + } + + @Builder + SelectionMark(selected: boolean) { + Stack({ alignContent: Alignment.Center }) { + if (selected) { + Image($r('app.media.remote_actions_check')) + .width(20) + .height(20) + .objectFit(ImageFit.Contain) + } + } + .width(20) + .height(24) + } + + @Builder + MenuMessage(message: string) { + Text(message) + .width('100%') + .padding({ left: 20, right: 20, top: 18, bottom: 18 }) + .fontSize(13) + .fontColor(MUTED) + } + + @Builder + Composer() { + Column({ space: 6 }) { + if (this.state.errorText.length > 0) { + Text(this.state.errorText) + .width('100%') + .padding({ left: 12, right: 12 }) + .fontSize(12) + .fontColor('#C63E3E') + .maxLines(2) + } + Row({ space: 8 }) { + TextArea({ placeholder: RemoteI18n.t('remote.create.placeholder'), text: this.state.draft }) + .id('remote-create-composer') + .layoutWeight(1) + .height(56) + .fontSize(16) + .fontColor(INK) + .placeholderColor(SUBTLE) + .backgroundColor('#00000000') + .padding({ left: 6, right: 4, top: 8, bottom: 8 }) + .defaultFocus(true) + .maxLines(3) + .onChange((value: string, previewText?: PreviewText) => { + if (previewText && previewText.value.length > 0) { + return; + } + this.onDraftChange(value); + }) + Button() { + Image($r('app.media.gpt_composer_send')) + .width(40) + .height(40) + .objectFit(ImageFit.Contain) + .opacity(this.canSend() ? 1 : 0.36) + } + .width(42) + .height(42) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor('#00000000') + .enabled(this.canSend()) + .onClick(() => this.onSend()) + } + .width('100%') + .height(66) + .padding({ left: 12, right: 7, top: 5, bottom: 5 }) + .backgroundColor(CARD) + .borderRadius(25) + .border({ width: 1, color: SOFT }) + .shadow({ radius: 22, color: '#18000000', offsetY: 7 }) + } + .width('100%') + .padding({ left: 16, right: 16, bottom: 14 }) + } + + private canSend(): boolean { + return this.state.draft.trim().length > 0 && this.state.selectedDeviceId.length > 0 && + !this.state.isSubmitting; + } + + private keepComposerFocused(): void { + setTimeout(() => focusControl.requestFocus('remote-create-composer'), 30); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets index 8449b28155..42513e1641 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets @@ -30,19 +30,12 @@ export struct RemoteHomeView { @Event onClearPairing: () => void = () => {}; @Event onCreate: (agentType: string) => void = (_agentType: string) => {}; @Event onCreateAssistantSession: () => void = () => {}; - @Event onCreateInWorkspace: (path: string) => void = (_path: string) => {}; + @Event onCreateInWorkspace: (path: string, agentType: string) => void = (_path: string, _agentType: string) => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Local showRemoteActions: boolean = false; - @Local shimmerOffset: number = -72; @Local sortMode: string = 'project'; - aboutToAppear(): void { - setTimeout(() => { - this.shimmerOffset = 380; - }, 60); - } - build() { Stack() { Column() { @@ -56,13 +49,14 @@ export struct RemoteHomeView { this.openRemoteActions(); } }) - if (this.canUseRemote()) { + if (this.canUseRemote() || this.isConnecting()) { RemoteSessionList({ sessions: this.pageState.visibleSessions(), query: this.pageState.sessionQuery, sortMode: this.sortMode, workspaceName: this.pageState.workspaceName, workspacePath: this.pageState.workspacePath, + workspaceKind: this.pageState.workspaceKind, recentWorkspaces: this.pageState.recentWorkspaces, hasMoreSessions: this.pageState.hasMoreSessions, isBusy: this.isBusy || this.pageState.isLoadingSessions, @@ -72,8 +66,8 @@ export struct RemoteHomeView { onCreateAssistantSession: () => { this.onCreateAssistantSession(); }, - onCreateInWorkspace: (path: string) => { - this.onCreateInWorkspace(path); + onCreateInWorkspace: (path: string, agentType: string) => { + this.onCreateInWorkspace(path, agentType); }, onSelectWorkspace: (path: string) => { this.onSelectWorkspace(path); @@ -101,8 +95,6 @@ export struct RemoteHomeView { this.onCreateAssistantSession(); } }) - } else if (this.isConnecting()) { - this.ConnectingState() } else { this.DisconnectedState() } @@ -233,59 +225,6 @@ export struct RemoteHomeView { .padding({ bottom: 72 }) } - @Builder - ConnectingState() { - Column() { - Column({ space: 26 }) { - this.SkeletonLine(110, 0) - this.SkeletonLine(220, 110) - this.SkeletonLine(330, 220) - this.SkeletonLine(130, 330) - this.SkeletonLine(240, 440) - this.SkeletonLine(340, 550) - this.SkeletonLine(150, 660) - this.SkeletonLine(260, 770) - this.SkeletonLine(100, 880) - this.SkeletonLine(220, 990) - } - .width('100%') - .padding({ top: 24, left: 12, right: 12 }) - .layoutWeight(1) - - RemoteBottomBar({ - query: '', - isBusy: true, - onQueryChange: (_value: string) => {}, - onSearch: () => {}, - onCreate: () => {} - }) - } - .width('100%') - .layoutWeight(1) - } - - @Builder - SkeletonLine(width: number, delay: number) { - Stack({ alignContent: Alignment.Start }) { - Text('') - .width('100%') - .height('100%') - .backgroundColor('#EFEFEF') - Text('') - .width(46) - .height('100%') - .borderRadius(10) - .backgroundColor('#7AFFFFFF') - .translate({ x: this.shimmerOffset }) - .animation({ duration: 1550, delay, curve: Curve.Linear, iterations: -1 }) - } - .width(width) - .height(20) - .borderRadius(10) - .clip(true) - .alignSelf(ItemAlign.Start) - } - @Builder LargeDesktopGlyph() { Stack() { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index d271926bfc..a10a2e0aaf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -12,21 +12,24 @@ export struct RemoteSessionList { @Param isBusy: boolean = false; @Param workspaceName: string = ''; @Param workspacePath: string = ''; + @Param workspaceKind: string = 'normal'; @Param recentWorkspaces: RecentWorkspaceEntry[] = []; @Event onCreate: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onLoadMore: () => void = () => {}; @Event onCreateAssistantSession: () => void = () => {}; - @Event onCreateInWorkspace: (path: string) => void = (_path: string) => {}; + @Event onCreateInWorkspace: (path: string, agentType: string) => void = (_path: string, _agentType: string) => {}; @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; @Local collapsedWorkspacePaths: string[] = []; @Local projectVisibleSteps: string[] = []; + @Local visibleProjectCount: number = 3; @Local chatsCollapsed: boolean = false; @Local chatVisibleCount: number = 3; @Local todayCollapsed: boolean = false; @Local yesterdayCollapsed: boolean = false; @Local earlierCollapsed: boolean = false; + @Local createMenuPath: string = ''; build() { Column() { @@ -152,13 +155,22 @@ export struct RemoteSessionList { @Builder private ProjectSection() { Column({ space: 4 }) { - Text(RemoteI18n.t('sidebar.projects')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - .margin({ bottom: 8 }) - ForEach(this.projectEntries(), (project: RecentWorkspaceEntry) => { + Row() { + Row({ space: 8 }) { + Text(RemoteI18n.t('sidebar.projects')) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + Text(`${this.projectEntries().length}`) + .fontSize(14) + .fontColor(MUTED) + } + Blank() + } + .width('100%') + .height(58) + .alignItems(VerticalAlign.Center) + ForEach(this.visibleProjectEntries(), (project: RecentWorkspaceEntry) => { Column({ space: 2 }) { Row({ space: 10 }) { Image($r('app.media.remote_ref_folder')) @@ -187,8 +199,24 @@ export struct RemoteSessionList { .height(25) .objectFit(ImageFit.Contain) .opacity(0.52) + .bindPopup(this.createMenuPath === project.path, { + builder: () => { + this.ProjectCreateMenu(project.path) + }, + placement: Placement.Top, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.createMenuPath = ''; + } + } + }) .onClick(() => { - this.onCreateInWorkspace(project.path); + this.createMenuPath = this.createMenuPath === project.path ? '' : project.path; }) } .width('100%') @@ -202,12 +230,54 @@ export struct RemoteSessionList { } .width('100%') .margin({ bottom: 8 }) - }) + }, (project: RecentWorkspaceEntry): string => project.path) + if (this.visibleProjectCount < this.projectEntries().length) { + Text(RemoteI18n.f('remote.projects.showMore', String(this.nextProjectEntryBatchSize()))) + .fontSize(15) + .fontColor(MUTED) + .height(48) + .width('100%') + .textAlign(TextAlign.Center) + .onClick(() => { + this.visibleProjectCount += 3; + }) + } } .width('100%') .margin({ top: 18 }) } + @Builder + private ProjectCreateMenu(path: string) { + Column({ space: 2 }) { + this.ProjectCreateMenuItem('Code', 'code', path) + this.ProjectCreateMenuItem('Cowork', 'Cowork', path) + } + .width(150) + .padding({ top: 8, bottom: 8 }) + .backgroundColor(CARD) + .borderRadius(14) + .border({ width: 1, color: '#18000000' }) + .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) + } + + @Builder + private ProjectCreateMenuItem(label: string, agentType: string, path: string) { + Row() { + Text(label) + .fontSize(15) + .fontColor(INK) + .fontWeight(FontWeight.Medium) + } + .width('100%') + .height(42) + .padding({ left: 18, right: 18 }) + .onClick(() => { + this.createMenuPath = ''; + this.onCreateInWorkspace(path, agentType); + }) + } + @Builder private ChatSection() { Column() { @@ -252,7 +322,7 @@ export struct RemoteSessionList { this.SessionRow(item) }) if (this.chatVisibleCount < this.visibleChatSessions().length) { - Text(`再显示 ${this.nextChatBatchSize()} 条`) + Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextChatBatchSize()))) .fontSize(15) .fontColor(MUTED) .height(48) @@ -285,7 +355,7 @@ export struct RemoteSessionList { }) }) if (this.projectVisibleCount(path) < this.projectSessions(path).length) { - Text(`再显示 ${this.nextProjectBatchSize(path)} 条`) + Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(path)))) .fontSize(15) .fontColor(MUTED) .height(44) @@ -299,17 +369,26 @@ export struct RemoteSessionList { private projectEntries(): RecentWorkspaceEntry[] { const entries: RecentWorkspaceEntry[] = []; - if (this.workspacePath.length > 0 || this.workspaceName.length > 0) { + if ((this.workspacePath.length > 0 || this.workspaceName.length > 0) && !this.isAssistantWorkspace(this.workspacePath)) { entries.push({ path: this.workspacePath, name: this.workspaceName, lastOpened: '', workspaceKind: 'normal' }); } this.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { - if (item.path.length > 0 && !entries.some((entry: RecentWorkspaceEntry) => entry.path === item.path)) { + if (item.path.length > 0 && !this.isAssistantWorkspace(item.path) && + !entries.some((entry: RecentWorkspaceEntry) => entry.path === item.path)) { entries.push(item); } }); return entries; } + private visibleProjectEntries(): RecentWorkspaceEntry[] { + return this.projectEntries().slice(0, this.visibleProjectCount); + } + + private nextProjectEntryBatchSize(): number { + return Math.min(3, this.projectEntries().length - this.visibleProjectCount); + } + private basename(path: string): string { const parts = path.split('/'); return parts.length > 0 ? parts[parts.length - 1] : path; @@ -397,7 +476,20 @@ export struct RemoteSessionList { private isAssistantSession(item: RemoteSession): boolean { const agentType = (item.agentType || '').toLowerCase(); - return agentType === 'claw' || agentType === 'assistant' || agentType === 'chat'; + return agentType === 'claw' || agentType === 'assistant' || agentType === 'chat' || + this.isAssistantWorkspace(item.workspacePath || ''); + } + + private isAssistantWorkspace(path: string): boolean { + if (path.length === 0) { + return this.workspaceKind.toLowerCase() === 'assistant'; + } + if (path === this.workspacePath && this.workspaceKind.toLowerCase() === 'assistant') { + return true; + } + return this.recentWorkspaces.some((item: RecentWorkspaceEntry) => { + return item.path === path && item.workspaceKind.toLowerCase() === 'assistant'; + }); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 400a1b372b..f7a264d908 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -10,7 +10,11 @@ export struct SettingsSheet { @Prop generalChatApiUrl: string = ''; @Prop generalChatModelName: string = ''; @Prop hasGeneralChatApiKey: boolean = false; + @Prop accountUsername: string = ''; + @Prop authenticatedUserId: string = ''; + @Prop deviceId: string = ''; onClose: () => void = () => {}; + onOpenAccount: () => void = () => {}; onSaveGeneralChatConfig: ( apiUrl: string, apiKey: string, @@ -35,6 +39,8 @@ export struct SettingsSheet { .width('100%') .margin({ bottom: 30 }) + this.AccountEntry() + this.SectionTitle(RemoteI18n.t('settings.modelService.section')) this.ModelServiceCard() @@ -75,6 +81,26 @@ export struct SettingsSheet { .borderRadius({ topLeft: 34, topRight: 34 }) } + @Builder + AccountEntry() { + Row({ space: 12 }) { + Image($r('app.media.settings_profile_avatar_gw')) + .width(34).height(34).objectFit(ImageFit.Contain) + Column({ space: 2 }) { + Text(RemoteI18n.t('remote.settings.profile')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) + Text(this.accountUsername || RemoteI18n.t('remote.settings.accountNotSignedIn')) + .fontSize(13).fontColor(SETTINGS_ROW_VALUE) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + }.layoutWeight(1).alignItems(HorizontalAlign.Start) + Image($r('app.media.settings_chevron_right')) + .width(10).height(14).objectFit(ImageFit.Contain).opacity(0.52) + } + .width('100%').height(64).padding({ left: 18, right: 18 }) + .backgroundColor(CARD).borderRadius(8).margin({ bottom: 24 }) + .onClick(() => this.onOpenAccount()) + } + @Builder SectionTitle(title: string) { Text(title) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index 62485f4402..89bacf2425 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -1,4 +1,3 @@ -import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MUTED } from './Theme'; @Component @@ -9,28 +8,58 @@ export struct ThinkingBlock { @Prop streaming: boolean = false; @Prop streamKey: string = ''; onCopyText: (text: string) => void = (_text: string) => {}; + @State dotPhase: number = 0; + private dotTimerId: number = 0; + + aboutToAppear(): void { + if (this.isRunning()) { + this.dotTimerId = setInterval(() => { + this.dotPhase = (this.dotPhase + 1) % 3; + }, 360); + } + } + + aboutToDisappear(): void { + if (this.dotTimerId !== 0) { + clearInterval(this.dotTimerId); + this.dotTimerId = 0; + } + } build() { if (this.isRunning()) { Row({ space: 6 }) { - Text('•••') - .fontSize(14) - .fontColor('#2F80ED') - .letterSpacing(0) - Text(RemoteI18n.t('chat.thinkingInProgress')) - .fontSize(14) + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.dotOpacity(0)) + .animation({ duration: 180, curve: Curve.EaseInOut }) + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.dotOpacity(1)) + .animation({ duration: 180, curve: Curve.EaseInOut }) + Text('•') + .width(6) + .height(18) + .fontSize(16) .fontColor(MUTED) - Text('•••') - .fontSize(14) - .fontColor('#2F80ED') - .letterSpacing(0) + .opacity(this.dotOpacity(2)) + .animation({ duration: 180, curve: Curve.EaseInOut }) } - .width('100%') - .height(28) - .padding({ left: 0, right: 0, top: 2, bottom: 2 }) + .height(24) + .padding({ left: 2 }) } } + private dotOpacity(index: number): number { + return this.dotPhase === index ? 1.0 : 0.34; + } + private isRunning(): boolean { const normalized = (this.status || '').toLowerCase(); return normalized === 'active' || normalized === 'running'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index 5dca30712b..28bda5663f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,4 +1,4 @@ -import { RemoteQuestionAnswerPayload, RemoteToolStatusResponse } from '../../model/RemoteModels'; +import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ACCENT, CARD, INK, LINE, MUTED, RED } from './Theme'; @@ -6,7 +6,6 @@ interface QuestionPreview { header?: string; question?: string; } - interface QuestionPreviewPayload { question?: string; prompt?: string; @@ -41,31 +40,31 @@ interface ToolInputSummaryPayload { interface ToolRenderEntry { type: string; key: string; - tool?: RemoteToolStatusResponse; - tools: RemoteToolStatusResponse[]; + tool?: ConversationUiToolStatus; + tools: ConversationUiToolStatus[]; readCount: number; searchCount: number; commandCount: number; otherCount: number; } -@Component +@ComponentV2 export struct ToolStatusList { - @Prop tools: RemoteToolStatusResponse[] = []; - @Prop isBusy: boolean = false; - onApproveTool: (toolId: string, updatedInput?: Object) => void = + @Param tools: ConversationUiToolStatus[] = []; + @Param isBusy: boolean = false; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; - onRejectTool: (toolId: string) => void = (_toolId: string) => {}; - onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = - (_toolId: string, _answers: RemoteQuestionAnswerPayload) => {}; - @State questionAnswerToolId: string = ''; - @State questionAnswerText: string = ''; - @State toolInputEditToolId: string = ''; - @State toolInputEditText: string = ''; - @State toolInputEditError: string = ''; - @State expanded: boolean = false; - @State expandedToolKey: string = ''; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Local questionAnswerToolId: string = ''; + @Local questionAnswerText: string = ''; + @Local toolInputEditToolId: string = ''; + @Local toolInputEditText: string = ''; + @Local toolInputEditError: string = ''; + @Local expanded: boolean = false; + @Local expandedToolKey: string = ''; build() { Column({ space: 4 }) { @@ -102,7 +101,7 @@ export struct ToolStatusList { } @Builder - ToolRow(tool: RemoteToolStatusResponse, index: number) { + ToolRow(tool: ConversationUiToolStatus, index: number) { Column({ space: 4 }) { Row({ space: 8 }) { this.ToolStatusIcon(tool) @@ -140,20 +139,18 @@ export struct ToolStatusList { Row({ space: 8 }) { Text(RemoteI18n.t('chat.approve')) .fontSize(12) - .fontColor(this.isBusy ? MUTED : CARD) + .fontColor(CARD) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) - .backgroundColor(this.isBusy ? '#EDEBE6' : ACCENT) + .backgroundColor(ACCENT) .borderRadius(16) .onClick(() => { - if (!this.isBusy) { - this.approveToolWithInput(tool); - } + this.approveToolWithInput(tool); }) Text(RemoteI18n.t('chat.reject')) .fontSize(12) - .fontColor(this.isBusy ? MUTED : INK) + .fontColor(INK) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) @@ -161,9 +158,7 @@ export struct ToolStatusList { .borderRadius(16) .border({ width: 1, color: LINE }) .onClick(() => { - if (!this.isBusy) { - this.onRejectTool(tool.id || ''); - } + this.onRejectTool(tool.id || ''); }) } .width('100%') @@ -177,7 +172,7 @@ export struct ToolStatusList { Blank() Text(RemoteI18n.t('chat.cancelTool')) .fontSize(12) - .fontColor(this.isBusy ? MUTED : INK) + .fontColor(INK) .textAlign(TextAlign.Center) .height(32) .padding({ left: 14, right: 14 }) @@ -185,9 +180,7 @@ export struct ToolStatusList { .borderRadius(16) .border({ width: 1, color: LINE }) .onClick(() => { - if (!this.isBusy) { - this.onCancelTool(tool.id || ''); - } + this.onCancelTool(tool.id || ''); }) } .width('100%') @@ -207,7 +200,7 @@ export struct ToolStatusList { } @Builder - ToolStatusIcon(tool: RemoteToolStatusResponse) { + ToolStatusIcon(tool: ConversationUiToolStatus) { Stack({ alignContent: Alignment.BottomEnd }) { this.ToolTypeBadge(tool) if (this.shouldShowStatusBadge(tool)) { @@ -219,7 +212,7 @@ export struct ToolStatusList { } @Builder - TrailingChevron(tool: RemoteToolStatusResponse, index: number) { + TrailingChevron(tool: ConversationUiToolStatus, index: number) { if (this.canExpandTool(tool)) { this.ChevronIcon(this.expandedToolKey === this.toolKey(tool, index) ? 'down' : 'right') } else { @@ -242,7 +235,7 @@ export struct ToolStatusList { } @Builder - ToolTypeBadge(tool: RemoteToolStatusResponse) { + ToolTypeBadge(tool: ConversationUiToolStatus) { Stack({ alignContent: Alignment.Center }) { this.ToolTypeSymbol(tool) } @@ -277,7 +270,7 @@ export struct ToolStatusList { } @Builder - ToolTypeSymbol(tool: RemoteToolStatusResponse) { + ToolTypeSymbol(tool: ConversationUiToolStatus) { if (this.isQuestionLikeTool(tool)) { SymbolGlyph($r('sys.symbol.questionmark_circle')) .fontSize(14) @@ -374,7 +367,7 @@ export struct ToolStatusList { } @Builder - ToolStatusBadge(tool: RemoteToolStatusResponse) { + ToolStatusBadge(tool: ConversationUiToolStatus) { Text(this.toolStatusIcon(tool)) .width(10) .height(10) @@ -479,7 +472,7 @@ export struct ToolStatusList { } @Builder - ToolInputEditor(tool: RemoteToolStatusResponse) { + ToolInputEditor(tool: ConversationUiToolStatus) { Column({ space: 6 }) { Row() { Text(RemoteI18n.t('chat.toolInput')) @@ -490,11 +483,9 @@ export struct ToolStatusList { .fontSize(11) .fontColor(MUTED) .onClick(() => { - if (!this.isBusy) { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = this.defaultToolInputText(tool); - this.toolInputEditError = ''; - } + this.toolInputEditToolId = tool.id || ''; + this.toolInputEditText = this.defaultToolInputText(tool); + this.toolInputEditError = ''; }) } .width('100%') @@ -508,13 +499,11 @@ export struct ToolStatusList { .padding(10) .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) .defaultFocus(false) - .enabled(!this.isBusy) + .enabled(true) .onChange((value: string) => { - if (!this.isBusy) { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = value; - this.toolInputEditError = ''; - } + this.toolInputEditToolId = tool.id || ''; + this.toolInputEditText = value; + this.toolInputEditError = ''; }) if (this.toolInputErrorForTool(tool.id || '').length > 0) { Text(this.toolInputErrorForTool(tool.id || '')) @@ -527,7 +516,7 @@ export struct ToolStatusList { } @Builder - QuestionAnswer(tool: RemoteToolStatusResponse) { + QuestionAnswer(tool: ConversationUiToolStatus) { Column({ space: 8 }) { Text(this.questionPrompt(tool)) .fontSize(12) @@ -542,12 +531,10 @@ export struct ToolStatusList { .padding(12) .border({ width: 1, color: LINE }) .defaultFocus(false) - .enabled(!this.isBusy) + .enabled(true) .onChange((value: string) => { - if (!this.isBusy) { - this.questionAnswerToolId = tool.id || ''; - this.questionAnswerText = value; - } + this.questionAnswerToolId = tool.id || ''; + this.questionAnswerText = value; }) Row({ space: 8 }) { Text(RemoteI18n.t('chat.submitAnswer')) @@ -561,7 +548,7 @@ export struct ToolStatusList { .onClick(() => { if (this.canSubmitQuestion(tool.id || '')) { const answer = this.questionAnswerText.trim(); - const answers: RemoteQuestionAnswerPayload = { + const answers: ConversationUiQuestionAnswer = { answer, '0': answer }; @@ -597,7 +584,7 @@ export struct ToolStatusList { return status || RemoteI18n.t('common.waiting'); } - private isEmphasizedToolRow(tool: RemoteToolStatusResponse, index: number): boolean { + private isEmphasizedToolRow(tool: ConversationUiToolStatus, index: number): boolean { return this.expandedToolKey === this.toolKey(tool, index) || this.hasToolError(tool) || this.isPendingConfirmation(tool) || @@ -605,26 +592,26 @@ export struct ToolStatusList { this.isRunningTool(tool); } - private toolRowBg(tool: RemoteToolStatusResponse, index: number): string { + private toolRowBg(tool: ConversationUiToolStatus, index: number): string { if (this.isEmphasizedToolRow(tool, index)) { return '#FBFAF7'; } return '#00000000'; } - private toolRowBorderWidth(tool: RemoteToolStatusResponse, index: number): number { + private toolRowBorderWidth(tool: ConversationUiToolStatus, index: number): number { return this.isEmphasizedToolRow(tool, index) ? 1 : 0; } - private toolRowRadius(tool: RemoteToolStatusResponse, index: number): number { + private toolRowRadius(tool: ConversationUiToolStatus, index: number): number { return this.isEmphasizedToolRow(tool, index) ? 14 : 8; } - private toolRowVerticalPadding(tool: RemoteToolStatusResponse, index: number): number { + private toolRowVerticalPadding(tool: ConversationUiToolStatus, index: number): number { return this.isEmphasizedToolRow(tool, index) ? 6 : 0; } - private toolRowHorizontalPadding(tool: RemoteToolStatusResponse, index: number): number { + private toolRowHorizontalPadding(tool: ConversationUiToolStatus, index: number): number { return this.isEmphasizedToolRow(tool, index) ? 10 : 0; } @@ -634,9 +621,9 @@ export struct ToolStatusList { } const entries: ToolRenderEntry[] = []; - let collapsed: RemoteToolStatusResponse[] = []; + let collapsed: ConversationUiToolStatus[] = []; let collapsedStart = 0; - this.tools.forEach((tool: RemoteToolStatusResponse, index: number) => { + this.tools.forEach((tool: ConversationUiToolStatus, index: number) => { if (this.shouldCollapseExploreTool(tool)) { if (collapsed.length === 0) { collapsedStart = index; @@ -654,9 +641,9 @@ export struct ToolStatusList { private expandedToolRenderEntries(): ToolRenderEntry[] { const entries: ToolRenderEntry[] = []; - let collapsed: RemoteToolStatusResponse[] = []; + let collapsed: ConversationUiToolStatus[] = []; let collapsedStart = 0; - this.tools.forEach((tool: RemoteToolStatusResponse, index: number) => { + this.tools.forEach((tool: ConversationUiToolStatus, index: number) => { if (this.shouldCollapseExploreTool(tool)) { if (collapsed.length === 0) { collapsedStart = index; @@ -672,7 +659,7 @@ export struct ToolStatusList { return entries; } - private toolEntry(tool: RemoteToolStatusResponse, index: number): ToolRenderEntry { + private toolEntry(tool: ConversationUiToolStatus, index: number): ToolRenderEntry { return { type: 'tool', key: this.toolKey(tool, index), @@ -687,14 +674,14 @@ export struct ToolStatusList { private flushCollapsedTools( entries: ToolRenderEntry[], - tools: RemoteToolStatusResponse[], + tools: ConversationUiToolStatus[], startIndex: number ): void { if (tools.length === 0) { return; } if (tools.length < 2) { - tools.forEach((tool: RemoteToolStatusResponse, index: number) => { + tools.forEach((tool: ConversationUiToolStatus, index: number) => { entries.push(this.toolEntry(tool, startIndex + index)); }); return; @@ -713,30 +700,30 @@ export struct ToolStatusList { private flushExpandedCollapsedTools( entries: ToolRenderEntry[], - tools: RemoteToolStatusResponse[], + tools: ConversationUiToolStatus[], startIndex: number ): void { if (tools.length === 0) { return; } if (tools.length < 2) { - tools.forEach((tool: RemoteToolStatusResponse, index: number) => { + tools.forEach((tool: ConversationUiToolStatus, index: number) => { entries.push(this.toolEntry(tool, startIndex + index)); }); return; } this.flushCollapsedTools(entries, tools, startIndex); - tools.forEach((tool: RemoteToolStatusResponse, index: number) => { + tools.forEach((tool: ConversationUiToolStatus, index: number) => { entries.push(this.toolEntry(tool, startIndex + index)); }); } - private collapsedToolStats(tools: RemoteToolStatusResponse[]): ToolRenderEntry { + private collapsedToolStats(tools: ConversationUiToolStatus[]): ToolRenderEntry { let readCount = 0; let searchCount = 0; let commandCount = 0; let otherCount = 0; - tools.forEach((tool: RemoteToolStatusResponse) => { + tools.forEach((tool: ConversationUiToolStatus) => { const kind = this.exploreToolKind(tool); if (kind === 'read') { readCount += 1; @@ -761,7 +748,7 @@ export struct ToolStatusList { private hasCollapsibleTools(): boolean { let runLength = 0; - return this.tools.some((tool: RemoteToolStatusResponse) => { + return this.tools.some((tool: ConversationUiToolStatus) => { if (this.shouldCollapseExploreTool(tool)) { runLength += 1; return runLength >= 2; @@ -771,7 +758,7 @@ export struct ToolStatusList { }); } - private shouldCollapseExploreTool(tool: RemoteToolStatusResponse): boolean { + private shouldCollapseExploreTool(tool: ConversationUiToolStatus): boolean { if (this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool)) { return false; @@ -779,12 +766,12 @@ export struct ToolStatusList { return this.isCollapsibleExploreTool(tool) && (this.isCompletedTool(tool) || this.isCancelledTool(tool)); } - private isCollapsibleExploreTool(tool: RemoteToolStatusResponse): boolean { + private isCollapsibleExploreTool(tool: ConversationUiToolStatus): boolean { const kind = this.exploreToolKind(tool); return kind === 'read' || kind === 'search'; } - private exploreToolKind(tool: RemoteToolStatusResponse): string { + private exploreToolKind(tool: ConversationUiToolStatus): string { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); if (raw === 'Read' || normalized === 'read' || normalized === 'read_file' || @@ -820,30 +807,30 @@ export struct ToolStatusList { return parts.join(' · '); } - private normalizedToolName(tool: RemoteToolStatusResponse): string { + private normalizedToolName(tool: ConversationUiToolStatus): string { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } - private isQuestionLikeTool(tool: RemoteToolStatusResponse): boolean { + private isQuestionLikeTool(tool: ConversationUiToolStatus): boolean { const normalized = this.normalizedToolName(tool); return this.isQuestionTool(tool) || normalized === 'askuserquestion' || normalized === 'ask_user_question'; } - private isTodoTool(tool: RemoteToolStatusResponse): boolean { + private isTodoTool(tool: ConversationUiToolStatus): boolean { return this.normalizedToolName(tool).indexOf('todo') >= 0; } - private isTaskTool(tool: RemoteToolStatusResponse): boolean { + private isTaskTool(tool: ConversationUiToolStatus): boolean { return this.normalizedToolName(tool) === 'task'; } - private isDirectoryListTool(tool: RemoteToolStatusResponse): boolean { + private isDirectoryListTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'LS' || normalized === 'ls' || normalized === 'list_directory'; } - private isFileReadTool(tool: RemoteToolStatusResponse): boolean { + private isFileReadTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Read' || raw === 'LS' || @@ -853,7 +840,7 @@ export struct ToolStatusList { normalized === 'list_directory'; } - private isFileMutationTool(tool: RemoteToolStatusResponse): boolean { + private isFileMutationTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Write' || @@ -889,7 +876,7 @@ export struct ToolStatusList { normalized === 'patch'; } - private isCommandTool(tool: RemoteToolStatusResponse): boolean { + private isCommandTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Bash' || @@ -914,13 +901,13 @@ export struct ToolStatusList { normalized.indexOf('exec_command') >= 0; } - private isGitTool(tool: RemoteToolStatusResponse): boolean { + private isGitTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Git' || normalized === 'git' || normalized.indexOf('git_') === 0; } - private isWebTool(tool: RemoteToolStatusResponse): boolean { + private isWebTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'WebFetch' || @@ -928,20 +915,20 @@ export struct ToolStatusList { normalized === 'web_fetch'; } - private isDiffTool(tool: RemoteToolStatusResponse): boolean { + private isDiffTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'GetFileDiff' || normalized === 'get_file_diff' || normalized === 'getfilediff'; } - private isDeleteTool(tool: RemoteToolStatusResponse): boolean { + private isDeleteTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Delete' || normalized === 'delete' || normalized === 'delete_file' || normalized === 'remove_file'; } - private isFileCreateTool(tool: RemoteToolStatusResponse): boolean { + private isFileCreateTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Write' || raw === 'Create' || @@ -951,7 +938,7 @@ export struct ToolStatusList { normalized === 'create_file'; } - private isPatchTool(tool: RemoteToolStatusResponse): boolean { + private isPatchTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'ApplyPatch' || @@ -961,7 +948,7 @@ export struct ToolStatusList { normalized === 'patch'; } - private isSearchTool(tool: RemoteToolStatusResponse): boolean { + private isSearchTool(tool: ConversationUiToolStatus): boolean { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); return raw === 'Grep' || @@ -974,7 +961,7 @@ export struct ToolStatusList { normalized.indexOf('search') >= 0; } - private operationLabel(tool: RemoteToolStatusResponse): string { + private operationLabel(tool: ConversationUiToolStatus): string { const raw = tool.name || 'Tool'; const normalized = this.normalizedToolName(tool); if (raw === 'TodoWrite' || normalized === 'todo_write' || normalized === 'todowrite') { @@ -1017,7 +1004,7 @@ export struct ToolStatusList { return raw; } - private summaryLabel(tool: RemoteToolStatusResponse): string { + private summaryLabel(tool: ConversationUiToolStatus): string { const parsed = this.toolInputPayload(tool); const fromPayload = this.summaryFromPayload(tool, parsed); if (fromPayload.length > 0) { @@ -1031,7 +1018,7 @@ export struct ToolStatusList { return tool.name || 'Tool'; } - private toolInputPayload(tool: RemoteToolStatusResponse): ToolInputSummaryPayload { + private toolInputPayload(tool: ConversationUiToolStatus): ToolInputSummaryPayload { if (tool.tool_input) { try { return JSON.parse(JSON.stringify(tool.tool_input)) as ToolInputSummaryPayload; @@ -1048,7 +1035,7 @@ export struct ToolStatusList { return {}; } - private summaryFromPayload(tool: RemoteToolStatusResponse, payload: ToolInputSummaryPayload): string { + private summaryFromPayload(tool: ConversationUiToolStatus, payload: ToolInputSummaryPayload): string { const name = tool.name || ''; if ((name === 'TodoWrite' || this.normalizedToolName(tool) === 'todowrite') && payload.todos && payload.todos.length > 0) { @@ -1099,7 +1086,7 @@ export struct ToolStatusList { return `${text.slice(0, limit)}...`; } - private toolLineLabel(tool: RemoteToolStatusResponse): string { + private toolLineLabel(tool: ConversationUiToolStatus): string { if (this.isRunningTool(tool)) { return `${RemoteI18n.t('chat.toolRunning')} “${this.compactText(this.operationTarget(tool), 24)}”`; } @@ -1115,7 +1102,7 @@ export struct ToolStatusList { return `${this.displayStatus(tool.status || 'pending')} · ${this.operationLabel(tool)}`; } - private operationTarget(tool: RemoteToolStatusResponse): string { + private operationTarget(tool: ConversationUiToolStatus): string { const summary = this.summaryLabel(tool); if (summary.length > 0 && summary !== tool.name) { return summary; @@ -1123,12 +1110,12 @@ export struct ToolStatusList { return this.operationLabel(tool); } - private canExpandTool(tool: RemoteToolStatusResponse): boolean { + private canExpandTool(tool: ConversationUiToolStatus): boolean { return this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool) || this.isCompletedTool(tool) || this.isCancelledTool(tool); } - private toolTypeColor(tool: RemoteToolStatusResponse): string { + private toolTypeColor(tool: ConversationUiToolStatus): string { if (this.isQuestionLikeTool(tool)) { return '#A15C00'; } @@ -1171,7 +1158,7 @@ export struct ToolStatusList { return MUTED; } - private toolTypeBg(tool: RemoteToolStatusResponse): string { + private toolTypeBg(tool: ConversationUiToolStatus): string { if (this.isQuestionLikeTool(tool)) { return '#FFF4DE'; } @@ -1214,7 +1201,7 @@ export struct ToolStatusList { return '#F5F4F0'; } - private toolTypeBorderColor(tool: RemoteToolStatusResponse): string { + private toolTypeBorderColor(tool: ConversationUiToolStatus): string { if (this.hasToolError(tool)) { return '#F0B3AA'; } @@ -1227,7 +1214,7 @@ export struct ToolStatusList { return '#00000000'; } - private toolStatusIcon(tool: RemoteToolStatusResponse): string { + private toolStatusIcon(tool: ConversationUiToolStatus): string { if (this.hasToolError(tool)) { return '!'; } @@ -1244,7 +1231,7 @@ export struct ToolStatusList { return '•'; } - private toolStatusColor(tool: RemoteToolStatusResponse): string { + private toolStatusColor(tool: ConversationUiToolStatus): string { if (this.hasToolError(tool)) { return RED; } @@ -1261,7 +1248,7 @@ export struct ToolStatusList { return MUTED; } - private shouldShowStatusBadge(tool: RemoteToolStatusResponse): boolean { + private shouldShowStatusBadge(tool: ConversationUiToolStatus): boolean { return this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || @@ -1294,7 +1281,7 @@ export struct ToolStatusList { return text.length <= 80 ? text : `${text.slice(0, 80)}...`; } - private toolInputPreview(tool: RemoteToolStatusResponse): string { + private toolInputPreview(tool: ConversationUiToolStatus): string { if (tool.input_preview && tool.input_preview.length > 0) { return tool.input_preview; } @@ -1308,7 +1295,7 @@ export struct ToolStatusList { return ''; } - private toolResultPreview(tool: RemoteToolStatusResponse): string { + private toolResultPreview(tool: ConversationUiToolStatus): string { const parts: string[] = []; if (tool.result_preview && tool.result_preview.length > 0) { parts.push(tool.result_preview); @@ -1332,22 +1319,22 @@ export struct ToolStatusList { return text.length <= 480 ? text : `${text.slice(0, 480)}...`; } - private hasToolError(tool: RemoteToolStatusResponse): boolean { + private hasToolError(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return status === 'failed' || status === 'error' || (tool.error_preview || '').length > 0 || (tool.stderr || '').length > 0; } - private isPendingConfirmation(tool: RemoteToolStatusResponse): boolean { + private isPendingConfirmation(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return (status === 'pending_confirmation' || status === 'needs_confirmation') && (tool.id || '').length > 0; } - private hasEditableToolInput(tool: RemoteToolStatusResponse): boolean { + private hasEditableToolInput(tool: ConversationUiToolStatus): boolean { return !!tool.tool_input; } - private defaultToolInputText(tool: RemoteToolStatusResponse): string { + private defaultToolInputText(tool: ConversationUiToolStatus): string { if (!tool.tool_input) { return ''; } @@ -1358,7 +1345,7 @@ export struct ToolStatusList { } } - private toolInputTextForTool(tool: RemoteToolStatusResponse): string { + private toolInputTextForTool(tool: ConversationUiToolStatus): string { const toolId = tool.id || ''; if (this.toolInputEditToolId === toolId) { return this.toolInputEditText; @@ -1370,10 +1357,7 @@ export struct ToolStatusList { return this.toolInputEditToolId === toolId ? this.toolInputEditError : ''; } - private approveToolWithInput(tool: RemoteToolStatusResponse): void { - if (this.isBusy) { - return; - } + private approveToolWithInput(tool: ConversationUiToolStatus): void { const toolId = tool.id || ''; if (toolId.length === 0) { return; @@ -1405,22 +1389,22 @@ export struct ToolStatusList { } } - private isRunningTool(tool: RemoteToolStatusResponse): boolean { + private isRunningTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return (status === 'running' || status === 'active') && (tool.id || '').length > 0; } - private isCompletedTool(tool: RemoteToolStatusResponse): boolean { + private isCompletedTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return status === 'completed' || status === 'done' || status === 'sent'; } - private isCancelledTool(tool: RemoteToolStatusResponse): boolean { + private isCancelledTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return status === 'cancelled' || status === 'canceled' || status === 'rejected'; } - private isQuestionTool(tool: RemoteToolStatusResponse): boolean { + private isQuestionTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); const name = tool.name || ''; return name === 'AskUserQuestion' && @@ -1434,7 +1418,7 @@ export struct ToolStatusList { (tool.id || '').length > 0; } - private questionPrompt(tool: RemoteToolStatusResponse): string { + private questionPrompt(tool: ConversationUiToolStatus): string { const preview = this.toolInputPreview(tool); const parsedQuestion = this.questionPromptFromJson(preview); if (parsedQuestion.length > 0) { @@ -1475,11 +1459,10 @@ export struct ToolStatusList { private canSubmitQuestion(toolId: string): boolean { return toolId.length > 0 && this.questionAnswerToolId === toolId && - this.questionAnswerText.trim().length > 0 && - !this.isBusy; + this.questionAnswerText.trim().length > 0; } - private toolKey(tool: RemoteToolStatusResponse, index: number): string { + private toolKey(tool: ConversationUiToolStatus, index: number): string { const signature = this.toolSignature(tool); if (tool.id && tool.id.length > 0) { return `${index}-${tool.id}-${signature}`; @@ -1487,7 +1470,7 @@ export struct ToolStatusList { return `${index}-${tool.name || 'tool'}-${signature}`; } - private toolSignature(tool: RemoteToolStatusResponse): string { + private toolSignature(tool: ConversationUiToolStatus): string { const parts: string[] = [ tool.status || 'pending', `${tool.duration_ms || 0}`, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets new file mode 100644 index 0000000000..70952627aa --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets @@ -0,0 +1,37 @@ +export interface AppRootHostPort { + attach(context: Context, uiContext: UIContext): void; + context(): Context; + animate(duration: number, callback: () => void): void; + showToast(message: string, duration: number): boolean; +} + +export class ArkUiAppRootHostAdapter implements AppRootHostPort { + private hostContext: Context = {} as Context; + private uiContext: UIContext = {} as UIContext; + + attach(context: Context, uiContext: UIContext): void { + this.hostContext = context; + this.uiContext = uiContext; + } + + context(): Context { + return this.hostContext; + } + + animate(duration: number, callback: () => void): void { + try { + this.uiContext.animateTo({ duration, curve: Curve.EaseOut }, callback); + } catch (_err) { + callback(); + } + } + + showToast(message: string, duration: number): boolean { + try { + this.uiContext.getPromptAction().showToast({ message, duration }); + return true; + } catch (_err) { + return false; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index 3876e2cf43..a32061fc0c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -2,6 +2,7 @@ export enum AppRoute { ChatHome = 'ChatHome', GeneralChat = 'GeneralChat', RemoteHome = 'RemoteHome', + RemoteCreate = 'RemoteCreate', RemoteChat = 'RemoteChat' } @@ -75,6 +76,9 @@ export class AppRouteContract { if (route === AppRoute.RemoteHome) { return AppNavigationBackAction.PopRemoteHome; } + if (route === AppRoute.RemoteCreate) { + return AppNavigationBackAction.PopRemoteHome; + } return AppNavigationBackAction.AllowSystem; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets new file mode 100644 index 0000000000..c29147b6ba --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets @@ -0,0 +1,2185 @@ +import { + ChatMessage, + RecentWorkspaceEntry, + RemoteModelCatalog, + RemotePermissionMode, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteSession, + SelectedImageAttachment, + SessionSummary, + WorkspaceInfo +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState } from '../../services/ChatTimelineStore'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigUpdate +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; +import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { + GeneralChatSendResult, + GeneralChatStreamCallbacks +} from '../../services/general-chat/GeneralChatPort'; +import { MobileIdentityStore } from '../../services/MobileIdentityStore'; +import { CloudAccountClient, CloudAccountDevice, CloudAccountRequestError, CloudAccountSession } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { Encoding } from '../../services/Encoding'; +import { AppRootRouteState } from '../../services/AppRootRouteState'; +import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { + RemoteChatPollingCursor, + RemoteChatPollingLifecycleController, + RemoteChatPollingSnapshot +} from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; +import { QrScanService } from '../../services/QrScanService'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + VoiceInputLifecycleController, + VoiceInputRouteSnapshot +} from '../../services/VoiceInputLifecycleController'; +import { VoiceInputService } from '../../services/VoiceInputService'; +import { ConversationIntent } from '../components/ConversationIntent'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { + AppRootPresentation, + AppRootPresentationActions, + ConnectPresentationActions, + RemoteCreatePresentationActions, + RemoteHomePresentationActions, + SettingsPresentationActions, + SidebarPresentationActions +} from '../components/AppRootPresentation'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract +} from '../navigation/AppRouteContract'; +import { AppShellState } from './AppShellState'; +import { AppShellViewModel } from './AppShellViewModel'; +import { + RemoteActivityViewModel, + RemoteActivityViewModelHooks +} from './RemoteActivityViewModel'; +import { + RemoteConnectionViewModel +} from './RemoteConnectionViewModel'; +import { + ConversationIntentDispatcher, + ConversationIntentDispatcherHooks +} from './ConversationIntentDispatcher'; +import { GeneralChatPageState } from './GeneralChatPageState'; +import { RemotePageState } from './RemotePageState'; +import { RemoteCreateSessionState } from './RemoteCreateSessionState'; +import { ConversationViewModel } from './ConversationViewModel'; +import { + RemoteWorkspaceViewModel, + RemoteWorkspaceViewModelHooks +} from './RemoteWorkspaceViewModel'; +import { + RemoteSessionViewModel, + RemoteSessionViewModelHooks +} from './RemoteSessionViewModel'; +import { + GeneralChatConversationViewModel, + GeneralChatConversationViewModelHooks +} from './GeneralChatConversationViewModel'; + +enum ConnectionState { + Idle = 'idle', + Parsing = 'parsing', + Pairing = 'pairing', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed', + Disconnected = 'disconnected' +} + +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; +const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; + +export class AppRootRuntime { + readonly host: AppRootHostPort; + + constructor(host: AppRootHostPort) { + this.host = host; + } + + readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly workspaceRepository: RemoteWorkspaceRepository = + new RemoteWorkspaceRepository(this.sessionManager); + readonly workspaceCoordinator: RemoteWorkspaceCoordinator = + new RemoteWorkspaceCoordinator(this.workspaceRepository); + readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); + readonly cloudAccountClient: CloudAccountClient = new CloudAccountClient(); + readonly cloudAccountSessionStore: CloudAccountSessionStore = new CloudAccountSessionStore(); + private cloudAccountSession?: CloudAccountSession; + private cloudAccountRelayUrl: string = ''; + private cloudTargetRestoreAttempted: boolean = false; + readonly clipboardService: ClipboardService = new ClipboardService(); + readonly qrScanService: QrScanService = new QrScanService(); + readonly imagePickerService: ImagePickerService = new ImagePickerService(); + readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); + readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = + new RemoteConnectionCoordinator( + this.sessionManager, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionGate + ); + readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); + readonly generalChatController: GeneralChatController = + GeneralChatController.createDefault(this.generalChatConfigStore); + readonly generalChatDraftController: GeneralChatDraftController = + new GeneralChatDraftController( + this.generalChatController, + GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, + (err: Error) => { + RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); + } + ); + readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = + new GeneralChatDraftLifecycleController( + this.generalChatDraftController, + GENERAL_CHAT_HOME_DRAFT_ID, + (): string => this.visibleGeneralChatDraftId() + ); + readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); + readonly generalChatCommandController: GeneralChatCommandController = + new GeneralChatCommandController( + this.generalChatController, + { + onSessions: (sessions: RemoteSession[]) => { + this.generalChatPageState.setSessions(sessions); + }, + onSessionPrepared: (sessionId: string) => { + this.resetGeneralChatTimeline(sessionId); + this.remoteModelController.clearCatalog(); + }, + onActiveSession: (session: SessionSummary) => { + this.generalChatPageState.setActiveSession(session); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.syncGeneralChatTimelineFromStore(); + }, + onClearComposer: () => { + this.generalChatPageState.clearComposer(); + }, + onChatInput: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + }, + onBusy: (isBusy: boolean) => { + this.generalChatPageState.setBusy(isBusy); + }, + onToast: (statusText: string) => { + this.showHomeToast(statusText); + } + } + ); + readonly generalChatBootstrapController: GeneralChatBootstrapController = + new GeneralChatBootstrapController( + this.generalChatConfigStore, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + { + onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { + this.applyGeneralChatConfig(snapshot); + }, + onHomeDraftRestored: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + } + } + ); + readonly voiceInputService: VoiceInputService = new VoiceInputService(); + readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = + new RemoteActivityLifecycleController(() => { + this.checkConnectionHealth(); + }); + readonly remoteActivityViewModel: RemoteActivityViewModel = + new RemoteActivityViewModel( + this.remoteActivityLifecycleController, + this.remoteConnectionCoordinator, + this.remoteResumeGate, + new RemoteActivityViewModelHooks( + (): boolean => this.connectionState === ConnectionState.Connected, + (): boolean => this.isBusy, + (): boolean => this.hasRemoteBindingForResume(), + (): boolean => this.isRoute(AppRoute.RemoteChat), + (): SessionSummary => this.activeSession, + (state: string): void => this.setRemoteConnectionState(state as ConnectionState), + (status: string): void => this.setRemoteStatusText(status), + async (err: Object): Promise => this.handleRemoteConnectionError(err), + (): void => this.stopHeartbeat(), + (): void => this.startPolling(), + (): void => this.stopPolling(), + async (): Promise => { + await this.pollActiveSession(); + }, + async (): Promise => { + await this.reconnectActiveRemote(); + }, + async (session: SessionSummary): Promise => { + this.remotePageState.setActiveSession(session); + await this.loadActiveMessages(); + } + ) + ); + isSyncingAfterTurn: boolean = false; + knownPollVersion: number = 0; + knownModelCatalogVersion: number = 0; + knownRemoteMessageCount: number = 0; + readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = + new GeneralChatStreamLifecycleController(); + readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); + readonly remotePageState: RemotePageState = new RemotePageState(); + readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = + new RemoteWorkspaceViewModel( + this.remotePageState, + this.workspaceCoordinator, + new RemoteWorkspaceViewModelHooks( + (): boolean => this.ensureRemoteAvailable(), + (): boolean => this.isBusy, + (isBusy: boolean): void => { + this.setRemoteBusy(isBusy); + }, + (statusText: string): void => { + this.setRemoteStatusText(statusText); + }, + (workspace: WorkspaceInfo): void => { + this.applyWorkspace(workspace); + this.remoteSessionController.clearSessions(); + }, + (sessions: RemoteSession[]): void => { + this.applyDiscoveredWorkspaceSessions(sessions); + }, + async (): Promise => { + await this.refreshSessions(); + }, + (error: Object): void => { + this.failRemoteConnection(error); + } + ) + ); + remoteWorkspaceSessions: RemoteSession[] = []; + readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); + readonly appShellState: AppShellState = this.appShellViewModel.state; + readonly voiceInputLifecycleController: VoiceInputLifecycleController = + new VoiceInputLifecycleController( + this.voiceInputService, + { + currentInputText: (): string => this.visibleChatInput(), + currentStatusText: (): string => this.visibleStatusText(), + onInputText: (routeId: string, text: string) => { + this.setChatInputForRoute(routeId as AppRoute, text); + }, + onListening: (routeId: string, isListening: boolean) => { + this.setVoiceListeningForRoute(routeId as AppRoute, isListening); + }, + onStatusText: (statusText: string) => { + this.setVisibleStatusText(statusText); + }, + onError: (message: string) => { + this.showVoiceInputError(message); + } + } + ); + readonly remoteSessionController: RemoteSessionController = + new RemoteSessionController( + this.sessionManager, + 8, + { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { + return item.workspacePath !== this.workspacePath; + }); + this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); + }, + onActiveSession: (session: SessionSummary) => { + this.remotePageState.setActiveSession(session); + }, + onStatusText: (statusText: string) => { + this.setRemoteStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.setRemoteBusy(isBusy); + }, + onLoading: (isLoading: boolean) => { + this.remotePageState.setLoading(isLoading); + }, + onSessionError: (errorText: string) => { + this.remotePageState.setError(errorText); + }, + onReconnecting: () => { + this.setRemoteConnectionState(ConnectionState.Reconnecting); + }, + onConnected: () => { + this.setRemoteConnectionState(ConnectionState.Connected); + }, + onConnectionFailed: (err: Object) => { + this.failRemoteConnection(err); + }, + onStartHeartbeat: () => { + this.startHeartbeat(); + } + } + ); + readonly remoteChatCommandController: RemoteChatCommandController = + new RemoteChatCommandController( + this.sessionManager, + { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.remotePageState.setHasMoreMessages(hasMoreMessages); + this.syncChatTimelineFromStore(); + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + this.knownRemoteMessageCount = knownMessageCount; + this.updateChatPollingCursor(pollVersion, knownMessageCount); + }, + onSendSucceeded: (turnId: string, pendingActiveId: string) => { + if (turnId.length > 0) { + this.chatTimelineStore.setLocalActiveTurn(turnId); + this.syncChatTimelineFromStore(); + } else if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + this.syncChatTimelineFromStore(); + } + this.nudgeChatPolling(); + }, + onSendFailed: ( + rawText: string, + images: SelectedImageAttachment[], + localMessageId: string, + pendingActiveId: string + ) => { + this.remotePageState.setChatInput(rawText); + this.remotePageState.setSelectedImages(images); + this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); + if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + } + this.syncChatTimelineFromStore(); + }, + onActiveSession: (session: SessionSummary) => { + this.remotePageState.setActiveSession(session); + }, + onSessionTitleChanged: (sessionId: string, title: string) => { + this.remoteSessionController.updateSessionTitle(sessionId, title); + }, + onStatusText: (statusText: string) => { + this.setRemoteStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.setRemoteBusy(isBusy); + }, + onPollRequested: () => { + this.pollActiveSession(); + } + } + ); + readonly remoteFileDownloadController: RemoteFileDownloadController = + new RemoteFileDownloadController( + this.sessionManager, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); + }, + () => { + this.remotePageState.clearDownloadingFilePath(); + }, + (statusText: string) => { + this.setRemoteStatusText(statusText); + }, + (isBusy: boolean) => { + this.setRemoteBusy(isBusy); + } + ); + readonly remoteToolActionController: RemoteToolActionController = + new RemoteToolActionController( + this.sessionManager, + (statusText: string) => { + this.setRemoteStatusText(statusText); + }, + (isBusy: boolean) => { + this.setRemoteBusy(isBusy); + }, + () => { + this.pollActiveSession(); + } + ); + readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = + new RemoteChatPollingLifecycleController( + this.sessionManager, + { + canPoll: (sessionId: string) => { + return this.activeSession.sessionId === sessionId && + this.isRoute(AppRoute.RemoteChat) && + this.ensureRemoteAvailable(); + }, + onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { + this.applyChatSessionSnapshot(snapshot); + }, + onError: (error: Object) => { + this.setRemoteStatusText(ConnectionErrorPolicy.errorText(error)); + } + } + ); + readonly remoteModelController: RemoteModelController = + new RemoteModelController( + this.sessionManager, + this.identityStore, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.knownModelCatalogVersion = knownModelCatalogVersion; + this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.knownModelCatalogVersion = knownModelCatalogVersion; + this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (statusText: string) => { + this.setRemoteStatusText(statusText); + }, + (isBusy: boolean) => { + this.setRemoteBusy(isBusy); + } + ); + readonly remoteSessionViewModel: RemoteSessionViewModel = + new RemoteSessionViewModel( + this.remotePageState, + this.remoteSessionController, + this.remoteChatCommandController, + this.remoteModelController, + this.remoteFileDownloadController, + new RemoteSessionViewModelHooks( + (): boolean => this.ensureRemoteAvailable(), + (): boolean => this.connectionState === ConnectionState.Connected, + (): boolean => this.isBusy, + (busy: boolean): void => this.setRemoteBusy(busy), + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), + (): void => this.replaceRoute(AppRoute.RemoteHome), + (): void => this.stopPolling(), + (): void => this.startPolling(), + (sessionId: string): void => this.resetChatTimeline(sessionId), + (): void => this.remoteFileDownloadController.clear(), + (): void => { + this.knownPollVersion = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + }, + async (sessionId: string): Promise => { + await this.remoteModelController.loadCatalog( + sessionId, + this.ensureRemoteAvailable(), + (activeSessionId: string): boolean => { + return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + } + ); + }, + async (): Promise => { + const sessionId = this.activeSession.sessionId || ''; + await this.remoteChatCommandController.loadMessages( + sessionId, + (activeSessionId: string): boolean => { + return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + } + ); + }, + async (): Promise => { + await this.remoteSessionController.refresh( + this.remotePageState.sessionQuery, + this.remotePageState.sessionFilter, + this.ensureRemoteAvailable(), + this.connectionState === ConnectionState.Connected + ); + }, + async (path: string): Promise => { + await this.selectWorkspace(path); + } + ) + ); + readonly generalChatConversationViewModel: GeneralChatConversationViewModel = + new GeneralChatConversationViewModel( + this.generalChatPageState, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + this.generalChatStreamLifecycleController, + this.chatTimelineStore, + new GeneralChatConversationViewModelHooks( + (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && + this.isGeneralChatVisible(), + (): string => this.currentActiveTurnId(), + (): string => this.latestUserMessageText(), + (): void => this.syncGeneralChatTimelineFromStore(), + (): void => this.generalChatCommandController.refreshSessions() + ) + ); + readonly remoteConnectionViewModel: RemoteConnectionViewModel = + new RemoteConnectionViewModel( + this.remotePageState, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionCoordinator, + this.remoteSessionController, + this.remoteModelController, + this.remoteFileDownloadController, + this.clipboardService, + this.qrScanService, + (sessionId: string): void => this.resetChatTimeline(sessionId), + (): void => { + this.knownPollVersion = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + }, + (): void => this.startHeartbeat(), + (): void => this.stopHeartbeat(), + (): void => this.stopPolling(), + async (): Promise => { + await this.loadRecentWorkspacesInBackground(); + }, + (route: AppRoute): void => this.replaceRoute(route), + (): void => this.appShellState.setConnectSheetVisible(false), + (): void => this.appShellState.setConnectSheetVisible(true) + ); + readonly conversationIntentDispatcher: ConversationIntentDispatcher = + new ConversationIntentDispatcher(new ConversationIntentDispatcherHooks( + (): void => this.openAppSidebar(), + (): void => this.closeActiveChat(), + (): void => { this.createSession('code'); }, + (): void => this.prepareNewGeneralChat(), + (): RemoteSession => this.activeGeneralChatAsRemoteSession(), + (): string => this.generalChatPageState.activeSession.sessionId, + (): boolean => this.generalChatPageState.isBusy, + (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, + async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { + await this.generalChatCommandController.pinSession(session, pinned, busy); + }, + async (session: RemoteSession): Promise => { await this.archiveHomeSession(session, true); }, + async (session: RemoteSession): Promise => { + await this.deleteHomeSession(session); + this.prepareNewGeneralChat(); + }, + (text: string): void => this.showHomeToast(text), + (): number => this.activeGeneralUploadedFileCount(), + async (): Promise => { await this.stopActiveChatTask(); }, + async (): Promise => { await this.loadOlderMessages(); }, + async (id: string, input?: Object): Promise => { await this.approveTool(id, input); }, + async (id: string): Promise => { await this.rejectTool(id); }, + async (id: string): Promise => { await this.cancelTool(id); }, + async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { + await this.answerQuestion(id, answers); + }, + async (title: string): Promise => { await this.renameVisibleSession(title); }, + async (text: string): Promise => { await this.copyMessage(text); }, + async (text: string): Promise => { await this.retryVisibleMessage(text); }, + async (id: string): Promise => { await this.selectModel(id); }, + async (): Promise => { await this.pickImages(); }, + (id: string): void => this.removeSelectedImage(id), + (path: string): void => this.downloadVisibleFile(path), + async (): Promise => { await this.sendVisibleChatMessage(); }, + async (): Promise => { await this.toggleVoiceInput(); }, + (route: AppRoute, value: string): void => this.onVisibleChatInputChange(route, value) + )); + readonly presentationActions: AppRootPresentationActions = new AppRootPresentationActions( + (route: AppRoute): boolean => this.handleNavigationBack(route), + (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), + (): void => this.closeAppSidebar(), + new RemoteHomePresentationActions( + (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), + (): void => this.openRemoteControlSettings(), (): void => { this.refreshSessions(); }, + (): void => { this.showRecentWorkspaces(); }, (): void => { this.showAssistants(); }, + (path: string): void => { this.selectWorkspace(path); }, (path: string): void => { this.selectAssistant(path); }, + (): void => this.remotePageState.setWorkspacePickerVisible(false), + (): void => this.remotePageState.setAssistantPickerVisible(false), + (query: string): void => this.remotePageState.setQuery(query), (): void => { this.refreshSessions(); }, + (): void => { this.loadMoreSessions(); }, (): void => { this.reconnect(); }, + (): void => { this.disconnect(false); }, (): void => { this.disconnect(true); }, + (agentType: string): void => { this.createSession(agentType); }, (): void => { this.openRemoteCreateSession(); }, + (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType); }, + (session: RemoteSession): void => this.openHomeSession(session), + (session: RemoteSession): void => { this.deleteHomeSession(session); } + ), + new RemoteCreatePresentationActions( + (): void => this.closeRemoteCreateSession(), + (): void => { this.toggleRemoteCreateDevices(); }, + (): void => { this.toggleRemoteCreateWorkspaces(); }, + (device: CloudAccountDevice): void => { this.selectRemoteCreateDevice(device); }, + (path: string): void => this.selectRemoteCreateWorkspace(path), + (value: string): void => this.remoteCreateState.setDraft(value), + (): void => { this.submitRemoteCreateSession(); } + ), + new SidebarPresentationActions( + (): void => this.closeAppSidebar(), + (): void => { this.closeAppSidebar(); this.prepareNewGeneralChat(); }, + (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, + (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, + (session: RemoteSession): void => { this.closeAppSidebar(); this.openHomeSession(session); }, + (session: RemoteSession, archived: boolean): void => { this.archiveHomeSession(session, archived); }, + (session: RemoteSession): void => { this.exportHomeSession(session); }, + (session: RemoteSession): void => { this.deleteHomeSession(session); } + ), + new SettingsPresentationActions( + (): void => this.appShellState.setSettingsVisible(false), + (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, + (): void => { this.reconnect(); }, + (): void => { + this.appShellState.setSettingsVisible(false); + setTimeout(() => this.appShellState.openSettings('account'), 180); + }, + (relayUrl: string, username: string, password: string): Promise => + this.loginCloudAccount(relayUrl, username, password), + (): Promise => this.syncCloudAccount(), + (): Promise => this.logoutCloudAccount(), + (): Promise => this.listCloudAccountDevices(), + (): Promise => this.getRemotePermissionMode(), + (mode: RemotePermissionMode): Promise => this.setRemotePermissionMode(mode), + async (url: string, key: string, model: string, clear: boolean): Promise => + this.saveGeneralChatConfig(url, key, model, clear) + ), + new ConnectPresentationActions( + (): void => this.appShellState.setConnectSheetVisible(false), + (password?: string): void => { + // Keep connection progress on the same RemoteHome surface as the + // connected state instead of showing a separate loading sheet. + this.appShellState.setConnectSheetVisible(false); + this.replaceRoute(AppRoute.RemoteHome); + this.connect(false, password || ''); + }, + (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, + (url: string): void => { this.setRemoteUrl(url); this.applyRemotePairingProjection(url); }, + (user: string): void => this.setRemoteUserId(user), + (url: string): boolean => this.handleDetectedRemoteUrl(url), + (visible: boolean): void => this.setRemoteUrlInputVisible(visible), + (): void => { this.pasteRemoteUrl(); }, (): void => { this.scanRemoteUrl(); }, + (): Promise => this.listCloudAccountDevices(), + (device: CloudAccountDevice): Promise => this.selectCloudAccountDevice(device) + ), + (): string => this.generalChatHomeStatusText() + ); + readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; + + + get remoteUrl(): string { return this.remotePageState.remoteUrl; } + get userId(): string { return this.remotePageState.userId; } + get authenticatedUserId(): string { return this.remotePageState.authenticatedUserId; } + get statusText(): string { return this.remotePageState.statusText; } + get connectionState(): ConnectionState { return this.remotePageState.connectionState as ConnectionState; } + get connectionFailureKind(): string { return this.remotePageState.connectionFailureKind; } + get isBusy(): boolean { return this.remotePageState.isBusy; } + get showRemoteUrlInput(): boolean { return this.remotePageState.showRemoteUrlInput; } + get workspaceName(): string { return this.remotePageState.workspaceName; } + get workspacePath(): string { return this.remotePageState.workspacePath; } + get workspaceBranch(): string { return this.remotePageState.workspaceBranch; } + get workspaceKind(): string { return this.remotePageState.workspaceKind; } + get assistantId(): string { return this.remotePageState.assistantId; } + get desktopName(): string { return this.remotePageState.desktopName; } + get desktopId(): string { return this.remotePageState.desktopId; } + get activeSession(): SessionSummary { return this.remotePageState.activeSession; } + get messages(): ChatMessage[] { return this.remotePageState.persistedMessages; } + get pendingMessages(): ChatMessage[] { return this.remotePageState.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.remotePageState.activeTurnMessage; } + get timelineItems(): ChatTimelineItem[] { return this.remotePageState.timelineItems; } + get hasMoreMessages(): boolean { return this.remotePageState.hasMoreMessages; } + + async aboutToAppear(): Promise { + this.syncRemotePageSummary(); + await this.generalChatBootstrapController.restore(this.host.context()); + await this.cloudAccountSessionStore.init(this.host.context()); + await this.restoreCloudAccountSession(); + await this.restoreIdentity(); + } + + onPageShow(): void { + RemoteLogger.info(`page show state=${this.connectionState} route=${this.currentRoute()}`); + this.resumeRemoteActivity(); + } + + onPageHide(): void { + RemoteLogger.info(`page hide state=${this.connectionState} route=${this.currentRoute()}`); + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.setRemoteBusy(false); + this.persistVisibleGeneralChatDraft(); + this.stopGeneralChatStream(true, 'failed'); + } + + aboutToDisappear(): void { + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.setRemoteBusy(false); + this.persistVisibleGeneralChatDraft(); + this.stopGeneralChatStream(true, 'failed'); + this.generalChatDraftLifecycleController.cancel(); + this.remoteFileDownloadController.cancel(); + this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { + this.setAllVoiceListening(false); + }); + } + + currentRoute(): AppRoute { + return this.appShellViewModel.currentRoute(); + } + + isGeneralComposerRoute(route: AppRoute): boolean { + return AppRootRouteState.isGeneralComposerRoute(route); + } + + visibleChatInput(): string { + return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); + } + + visibleSelectedImages(): SelectedImageAttachment[] { + return AppRootRouteState.selectedImages(this.currentRoute(), this.generalChatPageState, this.remotePageState); + } + + visibleVoiceListening(): boolean { + return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); + } + + setChatInputForRoute(route: AppRoute, value: string): void { + AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); + } + + setSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { + AppRootRouteState.setSelectedImages(route, images, this.generalChatPageState, this.remotePageState); + } + + addSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { + AppRootRouteState.addSelectedImages(route, images, this.generalChatPageState, this.remotePageState); + } + + removeSelectedImageForRoute(route: AppRoute, imageId: string): void { + AppRootRouteState.removeSelectedImage(route, imageId, this.generalChatPageState, this.remotePageState); + } + + clearComposerForRoute(route: AppRoute): void { + AppRootRouteState.clearComposer(route, this.generalChatPageState, this.remotePageState); + } + + setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { + AppRootRouteState.setVoiceListening( + route, + isVoiceListening, + this.generalChatPageState, + this.remotePageState + ); + } + + setAllVoiceListening(isVoiceListening: boolean): void { + this.generalChatPageState.setVoiceListening(isVoiceListening); + this.remotePageState.setVoiceListening(isVoiceListening); + } + + voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { + return AppRootRouteState.snapshot( + route, + this.visibleChatBusy(), + this.generalChatPageState, + this.remotePageState + ); + } + + isRoute(route: AppRoute): boolean { + return this.appShellViewModel.isRoute(route); + } + + isGeneralChatVisible(): boolean { + return this.appShellViewModel.isGeneralChatVisible(); + } + + pushRoute(route: AppRoute, sessionId: string = ''): void { + this.appShellViewModel.pushRoute(route, sessionId); + } + + replaceRoute(route: AppRoute, sessionId: string = ''): void { + this.appShellViewModel.replaceRoute(route, sessionId); + } + + popRoute(fallback: AppRoute): void { + this.appShellViewModel.popRoute(fallback); + } + + private routeCreatedRemoteSession(sessionId: string): void { + if (this.isRoute(AppRoute.RemoteCreate)) { + this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); + return; + } + this.pushRoute(AppRoute.RemoteChat, sessionId); + } + + handleNavigationBack(route: AppRoute): boolean { + const action = this.appShellViewModel.backAction(route); + if (action === AppNavigationBackAction.CloseSidebar) { + this.closeAppSidebar(); + return true; + } + if (action === AppNavigationBackAction.CloseActiveChat) { + this.closeActiveChat(); + return true; + } + if (action === AppNavigationBackAction.PopRemoteHome) { + this.popRoute(AppRoute.ChatHome); + return true; + } + return false; + } + + handleConversationIntent(route: AppRoute, intent: ConversationIntent): void { + this.conversationIntentDispatcher.dispatch(route, intent); + } + + + async saveGeneralChatConfig( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update: GeneralChatConfigUpdate = { + apiUrl, + apiKey, + modelName, + clearApiKey + }; + try { + const snapshot = await this.generalChatConfigStore.save(update); + this.applyGeneralChatConfig(snapshot); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + applyGeneralChatConfig(snapshot: GeneralChatConfigSnapshot): void { + this.generalChatPageState.setConfiguration( + snapshot.apiUrl, + snapshot.modelName, + snapshot.hasApiKey, + GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) + ); + } + + async restoreIdentity(): Promise { + if (this.remotePageState.controlTargetType === 'account_device' || this.cloudTargetRestoreAttempted) { + return; + } + await this.remoteConnectionViewModel.restore(this.host.context()); + } + + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { + await this.remoteConnectionViewModel.connect(autoReconnect, accountPassword); + await this.persistDelegatedAccountSession(); + } + + private async persistDelegatedAccountSession(): Promise { + if (this.cloudAccountSession) { + return; + } + const delegated = this.sessionManager.delegatedAccountSession(); + if (!delegated) { + return; + } + this.cloudAccountSession = delegated.session; + this.cloudAccountRelayUrl = delegated.relayUrl; + await this.cloudAccountSessionStore.save({ + relayUrl: delegated.relayUrl, + username: delegated.session.userId, + token: delegated.session.token, + userId: delegated.session.userId, + masterKey: Encoding.bytesToBase64(delegated.session.masterKey) + }); + this.remotePageState.setAccountUserId(delegated.session.userId); + this.remotePageState.setAccountUsername(delegated.session.userId); + RemoteLogger.info('delegated account session persisted after room pairing'); + } + + async reconnect(): Promise { + await this.remoteConnectionViewModel.reconnect(); + } + + async disconnect(clearPairing: boolean): Promise { + await this.remoteConnectionViewModel.disconnect(clearPairing); + } + + async pasteRemoteUrl(): Promise { + await this.remoteConnectionViewModel.paste(); + } + + async scanRemoteUrl(): Promise { + await this.remoteConnectionViewModel.scan(this.host.context()); + } + + handleDetectedRemoteUrl(remoteUrl: string): boolean { + return this.remoteConnectionViewModel.handleDetectedUrl(remoteUrl); + } + + applyWorkspace(workspace: WorkspaceInfo): void { + this.remoteConnectionViewModel.applyWorkspace(workspace); + } + + applyRemotePairingProjection(remoteUrl: string): void { + this.remoteConnectionViewModel.projectRemoteUrl(remoteUrl); + } + + ensureRemoteAvailable(): boolean { + return this.remoteConnectionViewModel.ensureAvailable(); + } + + setRemoteConnectionState(connectionState: ConnectionState): void { + this.remotePageState.setConnectionState(connectionState); + } + + setRemoteUrl(remoteUrl: string): void { + this.remotePageState.setRemoteUrl(remoteUrl); + } + + setRemoteUserId(userId: string): void { + this.remotePageState.setUserId(userId); + } + + setRemoteAuthenticatedUserId(authenticatedUserId: string): void { + this.remotePageState.setAuthenticatedUserId(authenticatedUserId); + } + + setRemoteStatusText(statusText: string): void { + this.remotePageState.setStatusText(statusText); + } + + setRemoteConnectionFailureKind(connectionFailureKind: string): void { + this.remotePageState.setConnectionFailureKind(connectionFailureKind); + } + + setRemoteBusy(isBusy: boolean): void { + this.remotePageState.setBusy(isBusy); + } + + setRemoteUrlInputVisible(visible: boolean): void { + this.remotePageState.setRemoteUrlInputVisible(visible); + } + + syncRemotePageSummary(): void { + if (this.remotePageState.statusText.length === 0) { + this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); + } + if (this.remotePageState.workspaceName.length === 0) { + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + } + } + + failRemoteConnection(err: Object): void { + this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); + this.setRemoteConnectionState(ConnectionState.Failed); + this.stopHeartbeat(); + } + + async showRecentWorkspaces(): Promise { + await this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); + } + + async showAssistants(): Promise { + await this.remoteWorkspaceViewModel.toggleAssistants(); + } + + async selectWorkspace(path: string): Promise { + await this.remoteWorkspaceViewModel.selectWorkspace(path); + } + + async selectAssistant(path: string): Promise { + await this.remoteWorkspaceViewModel.selectAssistant(path); + } + + async refreshSessions(): Promise { + await this.remoteSessionViewModel.refreshSessions(); + } + + async loadMoreSessions(): Promise { + await this.remoteSessionViewModel.loadMoreSessions(); + } + + setSessionFilter(filter: string): void { + this.remoteSessionViewModel.setFilter(filter); + } + + visibleChatBusy(): boolean { + return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? + this.generalChatPageState.isBusy : this.remotePageState.isBusy; + } + + visibleStatusText(): string { + return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? + this.generalChatPageState.statusText : this.remotePageState.statusText; + } + + setVisibleStatusText(statusText: string): void { + if (this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat)) { + this.generalChatPageState.setStatus(statusText); + return; + } + this.setRemoteStatusText(statusText); + } + + openAppSidebar(): void { + this.host.animate(230, () => { + this.appShellState.setSidebarVisible(true); + }); + } + + closeAppSidebar(): void { + this.host.animate(210, () => { + this.appShellState.setSidebarVisible(false); + }); + } + + enterCodeEntry(): void { + if (this.cloudAccountSession && this.remotePageState.accountUserId.trim().length > 0) { + this.appShellState.setConnectSheetVisible(true); + return; + } + if (RemoteUiState.canUseRemote(this.connectionState)) { + this.appShellState.setConnectSheetVisible(false); + this.pushRoute(AppRoute.RemoteHome); + return; + } + this.appShellState.setConnectSheetVisible(true); + } + + openAddConnection(): void { + this.appShellState.setConnectSheetVisible(true); + } + + openRemoteControlSettings(): void { + setTimeout(() => { + this.appShellState.openSettings('remote'); + }, 180); + } + + openAddConnectionFromSettings(): void { + this.appShellState.setSettingsVisible(false); + setTimeout(() => { + this.openAddConnection(); + }, 220); + } + + async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { + RemoteLogger.info('cloud account UI login requested'); + const session = await this.cloudAccountClient.login(relayUrl, username, password, this.identityStoreSnapshotInstallId()); + this.cloudAccountSession = session; + this.cloudAccountRelayUrl = relayUrl.trim(); + this.cloudTargetRestoreAttempted = false; + await this.cloudAccountSessionStore.save({ + relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }); + this.remotePageState.setAccountUserId(session.userId); + this.remotePageState.setAccountUsername(username.trim()); + RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); + RemoteLogger.info(`cloud account login success user=${session.userId}`); + return session.userId; + } + + private async restoreCloudAccountSession(): Promise { + try { + const persisted = await this.cloudAccountSessionStore.load(); + if (!persisted) return; + const session: CloudAccountSession = { + token: persisted.token, + userId: persisted.userId, + masterKey: Encoding.base64ToBytes(persisted.masterKey) + }; + this.cloudAccountSession = session; + this.cloudAccountRelayUrl = persisted.relayUrl; + this.remotePageState.setAccountUserId(session.userId); + this.remotePageState.setAccountUsername(persisted.username || session.userId); + this.cloudTargetRestoreAttempted = (persisted.targetDeviceId || '').trim().length > 0; + await this.restoreCloudTarget(persisted.targetDeviceId || '', persisted.targetDeviceName || ''); + } catch (err) { + RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + await this.cloudAccountSessionStore.clear(); + } + } + + async syncCloudAccount(): Promise { + if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + let bundles: Object[]; + try { + bundles = await this.cloudAccountClient.fetchSessions(this.cloudAccountRelayUrl, this.cloudAccountSession, 0); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); + } + RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); + return String(bundles.length); + } + + async logoutCloudAccount(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + this.remoteActivityViewModel.invalidate(); + this.stopPolling(); + this.stopHeartbeat(); + this.sessionManager.reset(); + this.remotePageState.clearActiveSession(); + this.remotePageState.setSessions([], false); + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + this.remotePageState.setConnectionState(ConnectionState.Disconnected); + this.remotePageState.setAuthenticatedUserId(''); + } + this.cloudAccountSession = undefined; + this.cloudAccountRelayUrl = ''; + this.cloudTargetRestoreAttempted = false; + await this.cloudAccountSessionStore.clear(); + this.remotePageState.setAccountUserId(''); + this.remotePageState.setAccountUsername(''); + this.remotePageState.clearControlTarget(); + RemoteLogger.info('cloud account logout success'); + } + + async listCloudAccountDevices(): Promise { + if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { + return []; + } + try { + return await this.cloudAccountClient.listDevices(this.cloudAccountRelayUrl, this.cloudAccountSession); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + if (err instanceof CloudAccountRequestError && + (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); + } + } + + async getRemotePermissionMode(): Promise { + if (!this.ensureRemoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return this.sessionManager.getPermissionMode(); + } + + async setRemotePermissionMode(mode: RemotePermissionMode): Promise { + if (!this.ensureRemoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return this.sessionManager.setPermissionMode(mode); + } + + private async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { + const targetId = targetDeviceId.trim(); + if (targetId.length === 0) { + return; + } + try { + const devices = await this.listCloudAccountDevices(); + const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); + if (!target || !target.online) { + const targetName = target?.deviceName || targetDeviceName || targetId; + this.remotePageState.setControlTarget('account_device', targetId, targetName); + this.remotePageState.setDesktopIdentity(targetName, targetId); + this.remotePageState.setConnectionState(ConnectionState.Failed); + this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); + return; + } + await this.selectCloudAccountDevice({ + deviceId: target.deviceId, + deviceName: target.deviceName || targetDeviceName || target.deviceId, + online: target.online, + lastSeenAt: target.lastSeenAt + }); + } catch (err) { + RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + private async expireCloudAccountSession(): Promise { + this.cloudAccountSession = undefined; + this.cloudAccountRelayUrl = ''; + this.cloudTargetRestoreAttempted = false; + await this.cloudAccountSessionStore.clear(); + this.remotePageState.setAccountUserId(''); + this.remotePageState.setAccountUsername(''); + if (this.remotePageState.controlTargetType === 'account_device') { + this.remoteActivityViewModel.invalidate(); + this.stopPolling(); + this.stopHeartbeat(); + this.sessionManager.reset(); + this.remotePageState.clearActiveSession(); + this.remotePageState.setSessions([], false); + this.remotePageState.clearControlTarget(); + this.remotePageState.setConnectionState(ConnectionState.Disconnected); + } + } + + private async handleRemoteConnectionError(err: Object): Promise { + if (this.remotePageState.controlTargetType !== 'account_device' || + !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { + return false; + } + await this.expireCloudAccountSession(); + this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); + return true; + } + + async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { + if (!device.online) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + const deviceId = device.deviceId.trim(); + if (deviceId.length === 0 || deviceId === this.remoteConnectionViewModel.getDeviceId()) { + return; + } + if (deviceId === this.remotePageState.controlTargetDeviceId && + this.connectionState === ConnectionState.Connected) { + this.appShellState.setConnectSheetVisible(false); + if (navigateHome) { + this.replaceRoute(AppRoute.RemoteHome); + } + return; + } + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.stopPolling(); + this.stopHeartbeat(); + // Do not keep presenting the previous device while the new account device + // is being handshaken. Clear its projection before the async connect. + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + this.remotePageState.clearControlTarget(); + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + this.remotePageState.setBusy(true); + this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); + this.remotePageState.clearActiveSession(); + this.resetChatTimeline(''); + this.knownPollVersion = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + this.remotePageState.setSessions([], false); + try { + const initialSync = await this.sessionManager.connectAccountDevice( + this.cloudAccountClient, + this.cloudAccountRelayUrl, + this.cloudAccountSession, + deviceId + ); + this.remotePageState.setControlTarget('account_device', deviceId, device.deviceName); + this.remotePageState.setDesktopIdentity(device.deviceName, deviceId); + this.remotePageState.setWorkspace( + initialSync.workspace.name, + initialSync.workspace.path, + initialSync.workspace.assistantId || '', + initialSync.workspace.gitBranch, + initialSync.workspace.workspaceKind || 'normal' + ); + this.remotePageState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); + this.remotePageState.setAuthenticatedUserId(initialSync.authenticatedUserId); + this.remotePageState.setConnectionState(ConnectionState.Connected); + this.remotePageState.setStatusText(RemoteI18n.t('connection.connected')); + this.appShellState.setSettingsVisible(false); + this.appShellState.setConnectSheetVisible(false); + if (navigateHome) { + this.replaceRoute(AppRoute.RemoteHome); + } + await this.cloudAccountSessionStore.save({ + relayUrl: this.cloudAccountRelayUrl, + username: this.remotePageState.accountUsername, + token: this.cloudAccountSession.token, + userId: this.cloudAccountSession.userId, + masterKey: Encoding.bytesToBase64(this.cloudAccountSession.masterKey), + targetDeviceId: deviceId, + targetDeviceName: device.deviceName + }); + this.startHeartbeat(); + await this.loadRecentWorkspacesInBackground(); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + } + this.remotePageState.clearControlTarget(); + this.remotePageState.setConnectionState(ConnectionState.Failed); + const message = ConnectionErrorPolicy.errorText(err); + this.remotePageState.setStatusText(message); + this.sessionManager.reset(); + throw new Error(message); + } finally { + this.remotePageState.setBusy(false); + } + } + + private identityStoreSnapshotInstallId(): string { + return this.remoteConnectionViewModel.getDeviceId(); + } + + openHomeSession(session: RemoteSession): void { + if (session.agentType === 'chat') { + this.openGeneralSession(session); + return; + } + this.openSession(session); + } + + async deleteHomeSession(session: RemoteSession): Promise { + if (session.agentType !== 'chat') { + await this.deleteSession(session); + return; + } + await this.generalChatCommandController.deleteSession(session, this.generalChatPageState.isBusy); + } + + activeGeneralChatAsRemoteSession(): RemoteSession { + const active = this.generalChatPageState.activeSession; + return { + id: active.sessionId, + title: active.title, + agentType: 'chat', + status: 'ready', + updatedAt: '', + createdAt: '', + messageCount: this.generalChatPageState.timelineItems.length, + workspacePath: active.workspacePath + }; + } + + activeGeneralUploadedFileCount(): number { + let count = 0; + this.generalChatPageState.timelineItems.forEach((item: ChatTimelineItem) => { + if (item.message && item.message.images) { + count += item.message.images.length; + } + }); + return count; + } + + async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { + await this.generalChatCommandController.archiveSession(session, archived, this.generalChatPageState.isBusy); + } + + async exportHomeSession(session: RemoteSession): Promise { + await this.generalChatCommandController.exportSession( + session, + this.generalChatPageState.isBusy, + async (text: string): Promise => { + await this.clipboardService.writeText(text); + } + ); + } + + async openGeneralSession(item: RemoteSession): Promise { + if (this.generalChatPageState.isBusy) { + return; + } + this.stopPolling(); + this.stopGeneralChatStream(false); + await this.generalChatCommandController.openSession( + item, + this.generalChatPageState.isBusy, + async (sessionId: string): Promise => { + return this.generalChatDraftLifecycleController.restore(sessionId); + }, + (sessionId: string) => { + this.replaceRoute(AppRoute.ChatHome, sessionId); + } + ); + } + + async startGeneralChat(text: string): Promise { + const trimmed = text.trim(); + if (trimmed.length === 0 || this.generalChatPageState.isBusy) { + return; + } + this.stopPolling(); + this.stopGeneralChatStream(false); + this.generalChatDraftLifecycleController.cancel(); + const created = await this.generalChatCommandController.createSession( + trimmed, + this.generalChatPageState.isBusy, + async (): Promise => { + await this.generalChatDraftLifecycleController.clearHomeNow(); + }, + (sessionId: string) => { + this.replaceRoute(AppRoute.ChatHome, sessionId); + } + ); + if (!created) { + return; + } + await this.sendGeneralChatMessage(); + } + + async sendVisibleChatMessage(): Promise { + if (this.isGeneralChatVisible()) { + if ((this.generalChatPageState.activeSession.sessionId || '').length === 0) { + this.startVisibleGeneralChat(); + return; + } + await this.sendGeneralChatMessage(); + return; + } + await this.sendChatMessage(); + } + + async stopActiveChatTask(): Promise { + if (this.isGeneralChatVisible()) { + this.stopGeneralChatStream(true); + return; + } + await this.stopActiveTask(); + } + + closeActiveChat(): void { + this.stopVoiceInput(false); + if (this.isRoute(AppRoute.GeneralChat)) { + this.persistVisibleGeneralChatDraft(); + this.stopGeneralChatStream(true); + this.popRoute(AppRoute.ChatHome); + this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); + return; + } + this.stopPolling(); + this.popRoute(AppRoute.RemoteHome); + } + + async renameVisibleSession(title: string): Promise { + if (this.isGeneralChatVisible()) { + await this.generalChatCommandController.renameActiveSession( + this.generalChatPageState.activeSession, + title + ); + return; + } + await this.renameActiveSession(title); + } + + async retryVisibleMessage(text: string): Promise { + if (this.isGeneralChatVisible()) { + const sessionId = this.generalChatPageState.activeSession.sessionId || ''; + const prepared = await this.generalChatCommandController.retryMessage( + sessionId, + text, + this.generalChatPageState.isBusy + ); + if (prepared) { + await this.sendGeneralChatMessage(); + } + return; + } + this.retryMessage(text); + } + + downloadVisibleFile(path: string): void { + if (this.isGeneralChatVisible()) { + this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); + return; + } + this.downloadFile(path); + } + + async createSession(agentType: string): Promise { + await this.remoteSessionViewModel.createSession(agentType); + } + + openRemoteCreateSession(): void { + if (!this.ensureRemoteAvailable()) { + return; + } + const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; + const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; + this.remoteCreateState.prepare(deviceId, deviceName); + if (deviceId.length > 0) { + this.remoteCreateState.setDevices([{ + deviceId, + deviceName: deviceName || deviceId, + online: true + }]); + } + this.remoteCreateState.setWorkspaces(this.remotePageState.recentWorkspaces); + this.pushRoute(AppRoute.RemoteCreate); + this.loadRemoteCreateChoices(); + } + + closeRemoteCreateSession(): void { + this.remoteCreateState.closeMenu(); + this.popRoute(AppRoute.RemoteHome); + } + + async loadRemoteCreateChoices(): Promise { + await Promise.all([ + this.loadRemoteCreateDevices(), + this.loadRemoteCreateWorkspaces() + ]); + } + + async loadRemoteCreateDevices(): Promise { + this.remoteCreateState.isLoadingDevices = this.remoteCreateState.devices.length === 0; + try { + const phoneDeviceId = this.remoteConnectionViewModel.getDeviceId(); + const accountDevices = await this.listCloudAccountDevices(); + const devices = accountDevices.filter((device: CloudAccountDevice): boolean => + device.online && device.deviceId !== phoneDeviceId + ); + const currentId = this.remoteCreateState.selectedDeviceId; + if (currentId.length > 0 && !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { + devices.unshift({ + deviceId: currentId, + deviceName: this.remoteCreateState.selectedDeviceName || currentId, + online: true + }); + } + this.remoteCreateState.setDevices(devices); + } catch (err) { + const currentId = this.remoteCreateState.selectedDeviceId; + if (currentId.length > 0) { + this.remoteCreateState.setDevices([{ + deviceId: currentId, + deviceName: this.remoteCreateState.selectedDeviceName || currentId, + online: true + }]); + } else { + this.remoteCreateState.setDevices([]); + } + this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); + } + } + + async loadRemoteCreateWorkspaces(): Promise { + this.remoteCreateState.isLoadingWorkspaces = this.remoteCreateState.workspaces.length === 0; + try { + const workspaces = await this.workspaceCoordinator.recentWorkspaces(); + this.remoteCreateState.setWorkspaces(workspaces); + } catch (err) { + this.remoteCreateState.setWorkspaces([]); + this.remoteCreateState.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); + } + } + + toggleRemoteCreateDevices(): void { + this.remoteCreateState.toggleMenu('devices'); + if (this.remoteCreateState.openMenu === 'devices' && this.remoteCreateState.devices.length === 0) { + this.loadRemoteCreateDevices(); + } + } + + toggleRemoteCreateWorkspaces(): void { + this.remoteCreateState.toggleMenu('workspaces'); + if (this.remoteCreateState.openMenu === 'workspaces' && this.remoteCreateState.workspaces.length === 0) { + this.loadRemoteCreateWorkspaces(); + } + } + + async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { + if (device.deviceId === this.remoteCreateState.selectedDeviceId) { + this.remoteCreateState.closeMenu(); + return; + } + const draft = this.remoteCreateState.draft; + this.remoteCreateState.closeMenu(); + this.remoteCreateState.isLoadingWorkspaces = true; + try { + await this.selectCloudAccountDevice(device, false); + this.remoteCreateState.selectDevice(device); + this.remoteCreateState.setDraft(draft); + await this.loadRemoteCreateWorkspaces(); + } catch (err) { + this.remoteCreateState.isLoadingWorkspaces = false; + this.remoteCreateState.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } + } + + selectRemoteCreateWorkspace(path: string): void { + const workspace = this.remoteCreateState.workspaces.find((item: RecentWorkspaceEntry): boolean => item.path === path); + this.remoteCreateState.selectWorkspace(workspace); + } + + async submitRemoteCreateSession(): Promise { + const instruction = this.remoteCreateState.draft.trim(); + if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { + return; + } + this.remoteCreateState.isSubmitting = true; + this.remoteCreateState.errorText = ''; + this.remoteCreateState.closeMenu(); + const workspacePath = this.remoteCreateState.selectedWorkspacePath; + try { + if (workspacePath.length > 0) { + await this.remoteSessionViewModel.createSessionInWorkspace(workspacePath, this.workspacePath, instruction); + } else { + await this.remoteSessionViewModel.createSession('Claw', instruction); + } + if (this.isRoute(AppRoute.RemoteCreate)) { + this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); + } + } catch (err) { + this.remoteCreateState.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.create.submitFailed'); + } finally { + this.remoteCreateState.isSubmitting = false; + } + } + + async createSessionInWorkspace(path: string, agentType: string = 'code'): Promise { + await this.remoteSessionViewModel.createSessionInWorkspace(path, this.workspacePath, '', agentType); + } + + applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { + this.remoteWorkspaceSessions = all; + const current = this.remotePageState.sessions; + const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.workspacePath); + this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + } + + async loadRecentWorkspacesInBackground(): Promise { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + } + + mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { + const merged = primary.slice(); + extras.forEach((item: RemoteSession) => { + if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { + merged.push(item); + } + }); + return merged; + } + + async openSession(item: RemoteSession): Promise { + await this.remoteSessionViewModel.openSession(item, this.workspacePath); + } + + async deleteSession(item: RemoteSession): Promise { + await this.remoteSessionViewModel.deleteSession(item, this.workspacePath); + } + + async loadActiveMessages(): Promise { + await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { + return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + }); + } + + async loadModelCatalog(sessionId: string): Promise { + await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { + return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + }); + } + + async selectModel(modelId: string): Promise { + await this.remoteSessionViewModel.selectModel(modelId); + } + + async loadOlderMessages(): Promise { + await this.remoteSessionViewModel.loadOlderMessages(this.knownPollVersion); + } + + async sendGeneralChatMessage(): Promise { + await this.generalChatConversationViewModel.sendMessage(); + return; + } + + stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { + this.generalChatConversationViewModel.stop(cancelled, finalStatus); + return; + } + + async sendChatMessage(): Promise { + if (this.remotePageState.isVoiceListening) { + await this.stopVoiceInput(false); + } + const rawText = this.remotePageState.chatInput.trim(); + const images = this.remotePageState.selectedImages.slice(); + const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + const sessionId = this.activeSession.sessionId || ''; + if ((!text && images.length === 0) || !sessionId || this.isBusy) { + return; + } + if (!this.ensureRemoteAvailable()) { + return; + } + this.remotePageState.clearComposer(); + const localMessage = RemoteUiState.localUserMessage(text, images); + this.chatTimelineStore.appendOptimisticMessage(localMessage); + const pendingActiveId = this.chatTimelineStore.setPendingActiveTurn(localMessage.id); + this.syncChatTimelineFromStore(); + RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); + this.startPolling(); + this.nudgeChatPolling(); + const imageContexts: RemoteImageContext[] = images.length > 0 ? this.imagePickerService.toRemoteContexts(images) : []; + await this.remoteChatCommandController.sendPreparedMessage( + sessionId, + text, + this.activeSession.agentType, + rawText, + images, + imageContexts, + localMessage.id, + pendingActiveId, + this.isBusy, + true + ); + } + + async toggleVoiceInput(): Promise { + const route = this.currentRoute(); + await this.voiceInputLifecycleController.toggle(this.host.context(), this.voiceInputSnapshot(route)); + } + + async stopVoiceInput(showStatus: boolean): Promise { + const route = this.currentRoute(); + await this.voiceInputLifecycleController.stop(this.voiceInputSnapshot(route), showStatus); + } + + showVoiceInputError(message: string): void { + const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); + this.setVisibleStatusText(text); + this.host.showToast(text, 2600); + } + + async pickImages(): Promise { + if (this.visibleChatBusy()) { + return; + } + const route = this.currentRoute(); + if (this.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + try { + this.setVisibleStatusText(RemoteI18n.t('status.pickImage')); + const picked = await this.imagePickerService.pickImages(3, this.visibleSelectedImages().length); + if (picked.length === 0) { + this.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); + return; + } + this.addSelectedImagesForRoute(route, picked); + this.setVisibleStatusText(RemoteI18n.f('status.imagesSelected', `${this.visibleSelectedImages().length}`)); + } catch (err) { + this.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + removeSelectedImage(imageId: string): void { + this.removeSelectedImageForRoute(this.currentRoute(), imageId); + } + + startVisibleGeneralChat(): void { + const rawText = this.generalChatPageState.chatInput.trim(); + const text = rawText.length > 0 ? rawText : + (this.generalChatPageState.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + if (text.length === 0 || this.generalChatPageState.isBusy) { + return; + } + if (this.generalChatPageState.serviceState === GeneralChatServiceState.Unconfigured) { + const statusText = GeneralChatServiceStatus.userMessage(this.generalChatPageState.serviceState); + this.generalChatPageState.setStatus(statusText); + this.showHomeToast(statusText); + return; + } + this.startGeneralChat(text); + } + + generalChatHomeStatusText(): string { + if (this.generalChatPageState.serviceState === GeneralChatServiceState.Ready || + this.generalChatPageState.serviceState === GeneralChatServiceState.Sending || + this.generalChatPageState.serviceState === GeneralChatServiceState.Streaming) { + return ''; + } + return GeneralChatServiceStatus.userMessage( + this.generalChatPageState.serviceState, + this.generalChatPageState.statusText + ); + } + + prepareNewGeneralChat(): void { + this.stopVoiceInput(false); + this.stopGeneralChatStream(true); + this.generalChatDraftLifecycleController.clearHome(); + this.generalChatPageState.clearComposer(); + this.generalChatPageState.clearActiveSession(); + this.resetGeneralChatTimeline(''); + this.replaceRoute(AppRoute.ChatHome); + } + + onVisibleChatInputChange(route: AppRoute, value: string): void { + this.setChatInputForRoute(route, value); + if (!this.isGeneralComposerRoute(route)) { + return; + } + this.generalChatDraftLifecycleController.scheduleVisible(value); + } + + visibleGeneralChatDraftId(): string { + if (this.isGeneralChatVisible()) { + return this.generalChatPageState.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID; + } + return ''; + } + + persistVisibleGeneralChatDraft(): void { + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + } + + async restoreGeneralChatDraft(draftId: string): Promise { + this.generalChatPageState.setChatInput(await this.generalChatDraftLifecycleController.restore(draftId)); + } + + latestUserMessageText(): string { + if (this.isGeneralChatVisible()) { + return this.generalChatPageState.latestUserMessageText(); + } + const candidates = this.messages.concat(this.pendingMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + showHomeToast(message: string): void { + if (!this.host.showToast(message, 2600)) { + this.setVisibleStatusText(message); + } + } + + async stopActiveTask(): Promise { + const sessionId = this.activeSession.sessionId || ''; + if (!sessionId) { + return; + } + await this.remoteChatCommandController.stopTask( + sessionId, + this.activeTurnMessage.id, + this.currentActiveTurnId(), + this.ensureRemoteAvailable() + ); + } + + async renameActiveSession(title: string): Promise { + const nextTitle = title.trim(); + if ( + !this.activeSession.sessionId || + nextTitle.length === 0 || + nextTitle === this.activeSession.title || + this.isBusy + ) { + return; + } + await this.remoteChatCommandController.renameActiveSession( + this.activeSession, + nextTitle, + this.isBusy, + this.ensureRemoteAvailable() + ); + } + + async copyMessage(text: string): Promise { + if (text.trim().length === 0) { + return; + } + try { + await this.clipboardService.writeText(text); + this.setRemoteStatusText(RemoteI18n.t('status.messageCopied')); + } catch (err) { + this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + async downloadFile(path: string): Promise { + const sessionId = this.activeSession.sessionId || ''; + await this.remoteFileDownloadController.download(path, sessionId, this.isBusy, this.ensureRemoteAvailable()); + } + + retryMessage(text: string): void { + if (this.isBusy) { + return; + } + if (!this.ensureRemoteAvailable()) { + return; + } + this.remotePageState.setChatInput(text); + this.sendChatMessage(); + } + + async approveTool(toolId: string, updatedInput?: Object): Promise { + await this.remoteToolActionController.approve( + toolId, + this.activeSession.sessionId || '', + this.isBusy, + this.ensureRemoteAvailable(), + updatedInput + ); + } + + async rejectTool(toolId: string): Promise { + await this.remoteToolActionController.reject( + toolId, + this.activeSession.sessionId || '', + this.isBusy, + this.ensureRemoteAvailable() + ); + } + + async cancelTool(toolId: string): Promise { + await this.remoteToolActionController.cancel( + toolId, + this.activeSession.sessionId || '', + this.isBusy, + this.ensureRemoteAvailable() + ); + } + + async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + await this.remoteToolActionController.answer( + toolId, + this.activeSession.sessionId || '', + this.isBusy, + this.ensureRemoteAvailable(), + answers + ); + } + + resetChatTimeline(sessionId: string): void { + this.chatTimelineStore.reset(sessionId); + this.knownPollVersion = 0; + this.syncChatTimelineFromStore(); + } + + resetGeneralChatTimeline(sessionId: string): void { + this.chatTimelineStore.reset(sessionId); + this.syncGeneralChatTimelineFromStore(); + } + + syncChatTimelineFromStore(): void { + const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); + const projectedItems: ChatTimelineItem[] = this.projectedTimelineItems(); + this.remotePageState.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + this.hasMoreMessages, + projectedItems + ); + this.remotePageState.setModelCatalog(state.modelCatalog, state.selectedModelId); + } + + syncGeneralChatTimelineFromStore(): void { + const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); + const projectedItems = this.chatTimelineStore.viewState(false); + this.generalChatPageState.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + false, + projectedItems + ); + const itemSummary = projectedItems.map((item: ChatTimelineItem) => { + const message = item.message; + return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; + }).join(','); + RemoteLogger.info(`general chat projection revision=${this.generalChatPageState.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); + } + + startPolling(): void { + this.remoteChatPollingLifecycleController.startActiveSession({ + sessionId: this.activeSession.sessionId || '', + cursor: this.currentChatPollingCursor(), + activeTurn: this.activeTurnMessage + }); + } + + stopPolling(): void { + this.remoteChatPollingLifecycleController.stop(); + } + + nudgeChatPolling(): void { + this.remoteChatPollingLifecycleController.nudge(); + } + + async pollActiveSession(): Promise { + await this.remoteChatPollingLifecycleController.pollNow(); + } + + currentChatPollingCursor(): RemoteChatPollingCursor { + return { + pollVersion: this.knownPollVersion, + knownMessageCount: this.knownRemoteMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }; + } + + updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { + this.knownPollVersion = pollVersion; + this.knownRemoteMessageCount = knownMessageCount; + this.remoteChatPollingLifecycleController.updateCursor({ + pollVersion, + knownMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }); + } + + applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { + if (snapshot.sessionId !== this.activeSession.sessionId || !this.isRoute(AppRoute.RemoteChat)) { + return; + } + this.chatTimelineStore.applySnapshot(snapshot); + this.syncChatTimelineFromStore(); + this.knownPollVersion = snapshot.cursor.pollVersion; + this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; + this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; + if (snapshot.title.length > 0) { + this.remotePageState.setActiveSession({ + sessionId: this.activeSession.sessionId, + title: snapshot.title, + workspacePath: this.activeSession.workspacePath, + agentType: this.activeSession.agentType + }); + } + if (snapshot.modelCatalog) { + this.remoteModelController.applyCatalog(snapshot.modelCatalog); + } + this.setRemoteStatusText(this.hasRunningActiveTurn() + ? RemoteI18n.t('status.desktopProcessing') + : RemoteI18n.t('status.messagesSynced')); + if (snapshot.shouldSyncAfterTurnEnded) { + this.syncAfterTurnEnded(); + } + } + + hasRunningActiveTurn(): boolean { + return this.activeTurnMessage.id.length > 0 && + (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + currentActiveTurnId(): string { + const activeTurnMessage = this.isGeneralChatVisible() ? + this.generalChatPageState.activeTurnMessage : this.activeTurnMessage; + if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { + return activeTurnMessage.turnId; + } + const activePrefix = 'active-'; + if (activeTurnMessage.id.indexOf(activePrefix) === 0) { + return activeTurnMessage.id.slice(activePrefix.length); + } + return ''; + } + + projectedTimelineItems(): ChatTimelineItem[] { + return this.chatTimelineStore.viewState(this.hasMoreMessages); + } + + startHeartbeat(): void { + this.remoteActivityViewModel.startHeartbeat(); + } + + stopHeartbeat(): void { + this.remoteActivityViewModel.stopHeartbeat(); + } + + async checkConnectionHealth(): Promise { + await this.remoteActivityViewModel.checkConnectionHealth(); + } + + resumeRemoteActivity(): void { + this.remoteActivityViewModel.resume(); + } + + hasRemoteBindingForResume(): boolean { + if (this.remotePageState.controlTargetType === 'account_device') { + return this.remotePageState.accountUserId.trim().length > 0 && + this.remotePageState.controlTargetDeviceId.trim().length > 0 && + this.connectionState !== ConnectionState.Idle && + this.connectionState !== ConnectionState.Disconnected; + } + return this.remoteUrl.trim().length > 0 && + this.userId.trim().length > 0 && + this.connectionState !== ConnectionState.Idle && + this.connectionState !== ConnectionState.Parsing && + this.connectionState !== ConnectionState.Pairing && + this.connectionState !== ConnectionState.Disconnected; + } + + private async reconnectActiveRemote(): Promise { + if (this.remotePageState.controlTargetType !== 'account_device') { + await this.connect(true); + return; + } + const targetId = this.remotePageState.controlTargetDeviceId; + const device = (await this.listCloudAccountDevices()).find((item: CloudAccountDevice): boolean => item.deviceId === targetId); + if (!device) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + await this.selectCloudAccountDevice(device); + } + + hasRemoteBindingForCodeHome(): boolean { + return this.remoteUrl.trim().length > 0 && + this.userId.trim().length > 0 && + (this.connectionState === ConnectionState.Connected || + this.connectionState === ConnectionState.Reconnecting || + this.connectionState === ConnectionState.Pairing || + this.connectionState === ConnectionState.Parsing); + } + + shortSessionId(sessionId: string): string { + if (sessionId.length <= 8) { + return sessionId; + } + return sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); + } + + async syncAfterTurnEnded(): Promise { + if (this.isSyncingAfterTurn) { + return; + } + this.isSyncingAfterTurn = true; + try { + await this.loadActiveMessages(); + } finally { + this.isSyncingAfterTurn = false; + } + } + +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets new file mode 100644 index 0000000000..c3a6bc0b55 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets @@ -0,0 +1,75 @@ +import { + AppNavigationBackAction, + AppNavigationPathSpec, + AppRoute, + AppRouteContract +} from '../navigation/AppRouteContract'; +import { AppShellState } from './AppShellState'; + +/** Owns application navigation and global overlay state. */ +export class AppShellViewModel { + readonly state: AppShellState = new AppShellState(); + readonly navigationStack: NavPathStack = new NavPathStack(); + + currentRoute(): AppRoute { + return AppRouteContract.currentRoute(this.navigationStack.getAllPathName()); + } + + isRoute(route: AppRoute): boolean { + return this.currentRoute() === route; + } + + isGeneralChatVisible(): boolean { + return AppRouteContract.isGeneralComposerRoute(this.currentRoute()); + } + + pushRoute(route: AppRoute, sessionId: string = ''): void { + const spec: AppNavigationPathSpec | undefined = AppRouteContract.pathSpec( + this.currentRoute(), + route, + sessionId + ); + if (!spec) { + return; + } + if (spec.hasSessionParam()) { + this.navigationStack.pushPath({ name: spec.name, param: spec.routeParam() }); + return; + } + this.navigationStack.pushPath({ name: spec.name }); + } + + replaceRoute(route: AppRoute, sessionId: string = ''): void { + this.navigationStack.clear(); + if (route !== AppRoute.ChatHome) { + this.pushRoute(route, sessionId); + } + } + + replaceCurrentRoute(route: AppRoute, sessionId: string = ''): void { + if (this.navigationStack.getAllPathName().length === 0) { + this.pushRoute(route, sessionId); + return; + } + if (sessionId.length > 0) { + this.navigationStack.replacePath({ + name: route, + param: AppRouteContract.routeParam(sessionId) + }, false); + return; + } + this.navigationStack.replacePath({ name: route }, false); + } + + popRoute(fallback: AppRoute): void { + if (this.navigationStack.getAllPathName().length > 0) { + this.navigationStack.pop(); + return; + } + this.replaceRoute(fallback); + } + + backAction(route: AppRoute): AppNavigationBackAction { + return AppRouteContract.backAction(route, this.state.showSidebar); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets new file mode 100644 index 0000000000..44a277409d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets @@ -0,0 +1,114 @@ +import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; +import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; + +export class ConversationIntentDispatcherHooks { + readonly openSidebar: () => void; + readonly back: () => void; + readonly newRemoteSession: () => void; + readonly newGeneralSession: () => void; + readonly activeGeneralSession: () => RemoteSession; + readonly activeGeneralSessionId: () => string; + readonly isGeneralBusy: () => boolean; + readonly isPinned: (sessionId: string) => boolean; + readonly pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise; + readonly archive: (session: RemoteSession) => Promise; + readonly delete: (session: RemoteSession) => Promise; + readonly showToast: (text: string) => void; + readonly uploadedFileCount: () => number; + readonly stop: () => Promise; + readonly loadOlder: () => Promise; + readonly approve: (toolId: string, input?: Object) => Promise; + readonly reject: (toolId: string) => Promise; + readonly cancel: (toolId: string) => Promise; + readonly answer: (toolId: string, answers: RemoteQuestionAnswerPayload) => Promise; + readonly rename: (title: string) => Promise; + readonly copy: (text: string) => Promise; + readonly retry: (text: string) => Promise; + readonly selectModel: (modelId: string) => Promise; + readonly pickImages: () => Promise; + readonly removeImage: (id: string) => void; + readonly downloadFile: (path: string) => void; + readonly send: () => Promise; + readonly voiceInput: () => Promise; + readonly inputChanged: (route: AppRoute, value: string) => void; + + constructor( + openSidebar: () => void, back: () => void, newRemoteSession: () => void, newGeneralSession: () => void, + activeGeneralSession: () => RemoteSession, activeGeneralSessionId: () => string, + isGeneralBusy: () => boolean, isPinned: (id: string) => boolean, + pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise, + archive: (session: RemoteSession) => Promise, deleteSession: (session: RemoteSession) => Promise, + showToast: (text: string) => void, uploadedFileCount: () => number, + stop: () => Promise, loadOlder: () => Promise, approve: (id: string, input?: Object) => Promise, + reject: (id: string) => Promise, cancel: (id: string) => Promise, + answer: (id: string, answers: RemoteQuestionAnswerPayload) => Promise, rename: (title: string) => Promise, + copy: (text: string) => Promise, retry: (text: string) => Promise, selectModel: (id: string) => Promise, + pickImages: () => Promise, removeImage: (id: string) => void, downloadFile: (path: string) => void, + send: () => Promise, voiceInput: () => Promise, inputChanged: (route: AppRoute, value: string) => void + ) { + this.openSidebar = openSidebar; this.back = back; this.newRemoteSession = newRemoteSession; + this.newGeneralSession = newGeneralSession; this.activeGeneralSession = activeGeneralSession; + this.activeGeneralSessionId = activeGeneralSessionId; this.isGeneralBusy = isGeneralBusy; + this.isPinned = isPinned; this.pin = pin; this.archive = archive; this.delete = deleteSession; + this.showToast = showToast; this.uploadedFileCount = uploadedFileCount; this.stop = stop; + this.loadOlder = loadOlder; this.approve = approve; this.reject = reject; this.cancel = cancel; + this.answer = answer; this.rename = rename; this.copy = copy; this.retry = retry; + this.selectModel = selectModel; this.pickImages = pickImages; this.removeImage = removeImage; + this.downloadFile = downloadFile; this.send = send; this.voiceInput = voiceInput; this.inputChanged = inputChanged; + } +} + +export class ConversationIntentDispatcher { + private readonly hooks: ConversationIntentDispatcherHooks; + + constructor(hooks: ConversationIntentDispatcherHooks) { + this.hooks = hooks; + } + + dispatch(route: AppRoute, intent: ConversationIntent): void { + switch (intent.type) { + case ConversationIntentType.OpenSidebar: this.hooks.openSidebar(); return; + case ConversationIntentType.Back: this.hooks.back(); return; + case ConversationIntentType.NewSession: + route === AppRoute.RemoteChat ? this.hooks.newRemoteSession() : this.hooks.newGeneralSession(); return; + case ConversationIntentType.TogglePin: + if (this.isGeneralSession(route)) { + const session = this.hooks.activeGeneralSession(); + void this.hooks.pin(session, !this.hooks.isPinned(session.id), this.hooks.isGeneralBusy()); + } + return; + case ConversationIntentType.Archive: + if (this.isGeneralSession(route)) void this.hooks.archive(this.hooks.activeGeneralSession()); + return; + case ConversationIntentType.Delete: + if (this.isGeneralSession(route)) void this.hooks.delete(this.hooks.activeGeneralSession()); + return; + case ConversationIntentType.ShowUploadedFiles: + const count = this.hooks.uploadedFileCount(); + this.hooks.showToast(count > 0 ? `当前会话已上传 ${count} 个文件` : '当前会话暂无已上传文件'); return; + case ConversationIntentType.Stop: void this.hooks.stop(); return; + case ConversationIntentType.LoadOlder: void this.hooks.loadOlder(); return; + case ConversationIntentType.ApproveTool: void this.hooks.approve(intent.toolId, intent.updatedInput); return; + case ConversationIntentType.RejectTool: void this.hooks.reject(intent.toolId); return; + case ConversationIntentType.CancelTool: void this.hooks.cancel(intent.toolId); return; + case ConversationIntentType.AnswerQuestion: + if (intent.answers) void this.hooks.answer(intent.toolId, toRemoteQuestionAnswer(intent.answers)); return; + case ConversationIntentType.RenameSession: void this.hooks.rename(intent.value); return; + case ConversationIntentType.CopyMessage: void this.hooks.copy(intent.value); return; + case ConversationIntentType.RetryMessage: void this.hooks.retry(intent.value); return; + case ConversationIntentType.SelectModel: void this.hooks.selectModel(intent.value); return; + case ConversationIntentType.PickImages: void this.hooks.pickImages(); return; + case ConversationIntentType.RemoveImage: this.hooks.removeImage(intent.value); return; + case ConversationIntentType.DownloadFile: this.hooks.downloadFile(intent.value); return; + case ConversationIntentType.Send: void this.hooks.send(); return; + case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return; + case ConversationIntentType.ChatInputChanged: this.hooks.inputChanged(route, intent.value); return; + } + } + + private isGeneralSession(route: AppRoute): boolean { + return AppRouteContract.isGeneralComposerRoute(route) && this.hooks.activeGeneralSessionId().length > 0; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets new file mode 100644 index 0000000000..6a90410d53 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets @@ -0,0 +1,22 @@ +import { ConversationEvent } from '../../services/ConversationEvent'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; + +/** + * Conversation-facing MVVM boundary. + * The store remains independently testable; the ViewModel is the page-facing + * owner of projected conversation state and transport-neutral events. + */ +export class ConversationViewModel extends ChatTimelineStore { + dispatch(event: ConversationEvent): void { + this.applyEvent(event); + } + + viewState(hasMoreMessages: boolean): ChatTimelineItem[] { + return this.project(hasMoreMessages); + } + + snapshotState(): ChatTimelineState { + return this.snapshot(); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets new file mode 100644 index 0000000000..06563fa70d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -0,0 +1,118 @@ +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + ChatComposerCapabilities, + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES +} from '../components/ChatComposerCapabilities'; +import { ChatSurface } from '../components/ChatSurface'; +import { + ConversationUiModelCatalog, + ConversationUiSelectedImage, + ConversationUiSession, + toConversationUiModelCatalog, + toConversationUiSelectedImage, + toConversationUiSession +} from '../components/ConversationUiModels'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { GeneralChatPageState } from './GeneralChatPageState'; +import { RemotePageState } from './RemotePageState'; + +/** Immutable presentation state consumed by ConversationView. */ +export class ConversationViewState { + activeSession: ConversationUiSession = ConversationViewState.emptySession(); + surface: ChatSurface = ChatSurface.General; + desktopName: string = ''; + workspaceBranch: string = ''; + statusText: string = ''; + inlineStatusText: string = ''; + connectionState: string = 'idle'; + composerCapabilities: ChatComposerCapabilities = GENERAL_CHAT_COMPOSER_CAPABILITIES; + isBusy: boolean = false; + canStop: boolean = false; + hasMoreMessages: boolean = false; + timelineItems: ChatTimelineItem[] = []; + timelineRevision: number = 0; + showSuggestionsWhenEmpty: boolean = true; + supportsSearch: boolean = false; + supportsImages: boolean = false; + supportsFiles: boolean = false; + modelCatalog: ConversationUiModelCatalog = toConversationUiModelCatalog(RemoteUiState.emptyModelCatalog()); + selectedModelId: string = ''; + isSessionPinned: boolean = false; + downloadingFilePath: string = ''; + downloadedFilePath: string = ''; + fileDownloadStatus: string = ''; + selectedImages: ConversationUiSelectedImage[] = []; + isVoiceListening: boolean = false; + chatInput: string = ''; + + static project( + route: AppRoute, + remote: RemotePageState, + general: GeneralChatPageState, + generalInlineStatus: string + ): ConversationViewState { + if (route === AppRoute.RemoteChat) { + return ConversationViewState.remote(remote); + } + return ConversationViewState.general(general, generalInlineStatus); + } + + private static remote(remote: RemotePageState): ConversationViewState { + const state = new ConversationViewState(); + state.activeSession = toConversationUiSession(remote.activeSession); + state.surface = ChatSurface.Remote; + state.desktopName = remote.desktopName; + state.workspaceBranch = remote.workspaceBranch; + state.statusText = remote.statusText; + state.connectionState = remote.connectionState; + state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + state.isBusy = remote.isBusy; + state.canStop = remote.hasRunningActiveTurn(); + state.hasMoreMessages = remote.hasMoreMessages; + state.timelineItems = remote.timelineItems; + state.timelineRevision = remote.timelineRevision; + state.showSuggestionsWhenEmpty = false; + state.modelCatalog = toConversationUiModelCatalog(remote.modelCatalog); + state.selectedModelId = remote.selectedModelId; + state.downloadingFilePath = remote.downloadingFilePath; + state.downloadedFilePath = remote.downloadedFilePath; + state.fileDownloadStatus = remote.fileDownloadStatus; + state.selectedImages = remote.selectedImages.map((image) => toConversationUiSelectedImage(image)); + state.isVoiceListening = remote.isVoiceListening; + state.chatInput = remote.chatInput; + return state; + } + + private static general(general: GeneralChatPageState, inlineStatus: string): ConversationViewState { + const state = new ConversationViewState(); + state.activeSession = toConversationUiSession(general.activeSession); + state.statusText = general.statusText; + state.inlineStatusText = inlineStatus; + state.connectionState = GeneralChatServiceStatus.connectionState(general.serviceState); + state.isBusy = general.isBusy; + state.canStop = general.hasRunningActiveTurn(); + state.hasMoreMessages = general.hasMoreMessages; + state.timelineItems = general.timelineItems; + state.timelineRevision = general.timelineRevision; + state.isSessionPinned = general.activeSession.sessionId.length > 0 && + general.pinnedSessionId() === general.activeSession.sessionId; + state.selectedImages = general.selectedImages.map((image) => toConversationUiSelectedImage(image)); + state.isVoiceListening = general.isVoiceListening; + state.chatInput = general.chatInput; + return state; + } + + private static emptySession(): ConversationUiSession { + return { + sessionId: '', + title: '', + workspacePath: '', + agentType: 'chat' + }; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets new file mode 100644 index 0000000000..cd489b440c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets @@ -0,0 +1,336 @@ +import { + ChatMessage, + ChatMessageItemResponse, + RemoteToolStatusResponse, + SelectedImageAttachment +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { Encoding } from '../../services/Encoding'; +import { ConversationViewModel } from './ConversationViewModel'; +import { GeneralChatPageState } from './GeneralChatPageState'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { + GeneralChatSendResult, + GeneralChatStreamCallbacks +} from '../../services/general-chat/GeneralChatPort'; +import { RemoteLogger } from '../../services/RemoteLogger'; + +export class GeneralChatConversationViewModelHooks { + readonly isVisible: (sessionId: string) => boolean; + readonly currentActiveTurnId: () => string; + readonly latestUserMessageText: () => string; + readonly syncTimeline: () => void; + readonly refreshSessions: () => void; + + constructor( + isVisible: (sessionId: string) => boolean, + currentActiveTurnId: () => string, + latestUserMessageText: () => string, + syncTimeline: () => void, + refreshSessions: () => void + ) { + this.isVisible = isVisible; + this.currentActiveTurnId = currentActiveTurnId; + this.latestUserMessageText = latestUserMessageText; + this.syncTimeline = syncTimeline; + this.refreshSessions = refreshSessions; + } +} + +/** Owns General Chat stream state and publishes all updates through ConversationViewModel. */ +export class GeneralChatConversationViewModel { + private readonly pageState: GeneralChatPageState; + private readonly command: GeneralChatCommandController; + private readonly drafts: GeneralChatDraftLifecycleController; + private readonly stream: GeneralChatStreamLifecycleController; + private readonly timeline: ConversationViewModel; + private readonly hooks: GeneralChatConversationViewModelHooks; + + constructor( + pageState: GeneralChatPageState, + command: GeneralChatCommandController, + drafts: GeneralChatDraftLifecycleController, + stream: GeneralChatStreamLifecycleController, + timeline: ConversationViewModel, + hooks: GeneralChatConversationViewModelHooks + ) { + this.pageState = pageState; + this.command = command; + this.drafts = drafts; + this.stream = stream; + this.timeline = timeline; + this.hooks = hooks; + } + + async sendMessage(): Promise { + const rawText = this.pageState.chatInput.trim(); + const images = this.pageState.selectedImages.slice(); + const text = rawText.length > 0 ? rawText : + (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + const sessionId = this.pageState.activeSession.sessionId || ''; + if ((!text && images.length === 0) || !sessionId || this.pageState.isBusy) { + return; + } + this.pageState.clearComposer(); + this.drafts.clear(sessionId); + const localMessage = this.localUserMessage(text, images); + this.timeline.appendOptimisticMessage(localMessage); + this.hooks.syncTimeline(); + try { + this.pageState.setBusy(true); + this.pageState.setServiceState(GeneralChatServiceState.Sending); + this.pageState.setStatus(RemoteI18n.t('generalChat.generating')); + this.beginStream(sessionId); + const observer = new GeneralChatStreamCallbacks( + async (message: ChatMessage): Promise => { + this.timeline.dispatch({ type: 'user_message', message, persisted: true }); + this.hooks.syncTimeline(); + }, + (delta: string): void => { + this.appendDelta(sessionId, delta); + } + ); + this.stream.setRequestInFlight(true); + const result: GeneralChatSendResult = await this.command.sendMessage( + sessionId, + text, + images, + observer + ); + this.stream.setRequestInFlight(false); + this.flushDelta(sessionId); + this.hooks.refreshSessions(); + if (this.stream.text().length > 0) { + this.finishStream(sessionId, result); + } else { + this.resetStream(); + this.timeline.dispatch({ type: 'user_message', message: result.userMessage, persisted: true }); + this.timeline.dispatch({ + type: 'turn_finished', + sessionId, + turnId: result.assistantMessage.turnId || this.hooks.currentActiveTurnId(), + message: result.assistantMessage + }); + await this.persistAssistantMessage(sessionId, result.assistantMessage); + this.hooks.syncTimeline(); + this.pageState.setBusy(false); + this.pageState.setServiceState(GeneralChatServiceState.Ready); + this.pageState.setStatus(RemoteI18n.t('generalChat.ready')); + } + } catch (err) { + await this.handleSendFailure(sessionId, rawText, images, err); + } + } + + stop(cancelled: boolean, finalStatus: string = 'cancelled'): void { + const hasStream = this.stream.hasActiveStream(); + if (!this.hooks.isVisible(this.pageState.activeSession.sessionId || '') && !hasStream) { + return; + } + const hadStream = hasStream || this.pageState.activeTurnMessage.id.length > 0; + const currentSessionId = this.stream.currentSessionId(); + const requestInFlight = this.stream.isRequestInFlight(); + if (cancelled && currentSessionId.length > 0) { + this.flushDelta(currentSessionId); + } + if (hasStream) { + this.stream.markRequestCancelled(); + this.command.cancelCurrentRequest(); + } + this.stream.reset(); + if (cancelled && this.pageState.activeTurnMessage.id.length > 0) { + const active = this.pageState.activeTurnMessage; + const stopped: ChatMessage = { + id: Encoding.randomId('assistant'), + turnId: this.hooks.currentActiveTurnId(), + role: 'assistant', + text: active.text || (finalStatus === 'failed' ? + RemoteI18n.t('generalChat.interrupted') : RemoteI18n.t('generalChat.stopped')), + status: finalStatus, + detail: finalStatus === 'failed' ? this.hooks.latestUserMessageText() : '', + timestamp: new Date().toISOString(), + items: this.assistantItems([], active.text || '') + }; + this.timeline.dispatch({ + type: 'turn_finished', + sessionId: this.pageState.activeSession.sessionId || '', + turnId: stopped.turnId || this.hooks.currentActiveTurnId(), + message: stopped + }); + if (requestInFlight) { + this.stream.setPendingFinalMessage(stopped); + } else { + this.persistAssistantMessage(this.pageState.activeSession.sessionId || '', stopped); + } + this.pageState.setStatus(finalStatus === 'failed' ? + RemoteI18n.t('generalChat.interrupted') : RemoteI18n.t('status.stopRequested')); + this.pageState.setServiceState(finalStatus === 'failed' ? + GeneralChatServiceState.Failed : GeneralChatServiceState.Ready); + } + if (this.hooks.isVisible(this.pageState.activeSession.sessionId || '')) { + this.hooks.syncTimeline(); + if (cancelled || hadStream) { + this.pageState.setBusy(false); + } + } + } + + reset(): void { + this.stream.reset(); + } + + private beginStream(sessionId: string): void { + const activeTurn = this.stream.begin(sessionId); + this.timeline.dispatch({ + type: 'turn_started', + sessionId, + turnId: activeTurn.turnId || activeTurn.id.replace('active-', '') + }); + this.hooks.syncTimeline(); + } + + private appendDelta(sessionId: string, delta: string): void { + const accepted = this.stream.append( + sessionId, + delta, + (): boolean => this.hooks.isVisible(sessionId), + (activeTurn: ChatMessage): void => { + this.timeline.dispatch({ type: 'active_turn_updated', sessionId, message: activeTurn }); + this.hooks.syncTimeline(); + } + ); + if (accepted) { + this.pageState.setServiceState(GeneralChatServiceState.Streaming); + } + } + + private flushDelta(sessionId: string): void { + const activeTurn = this.stream.flush(sessionId, this.hooks.isVisible(sessionId)); + if (!activeTurn) { + return; + } + this.timeline.dispatch({ type: 'active_turn_updated', sessionId, message: activeTurn }); + this.hooks.syncTimeline(); + } + + private finishStream(sessionId: string, result: GeneralChatSendResult): void { + const currentSessionId = this.stream.currentSessionId(); + const visible = this.hooks.isVisible(sessionId); + RemoteLogger.info(`general chat finish check session=${this.shortId(sessionId)} current=${this.shortId(currentSessionId)} visible=${visible}`); + if (currentSessionId !== sessionId && !visible) { + return; + } + this.timeline.dispatch({ type: 'user_message', message: result.userMessage, persisted: true }); + this.timeline.dispatch({ + type: 'turn_finished', + sessionId, + turnId: result.assistantMessage.turnId || this.hooks.currentActiveTurnId(), + message: result.assistantMessage + }); + this.persistAssistantMessage(sessionId, result.assistantMessage); + this.hooks.syncTimeline(); + this.resetStream(); + this.pageState.setBusy(false); + this.pageState.setServiceState(GeneralChatServiceState.Ready); + this.pageState.setStatus(RemoteI18n.t('generalChat.ready')); + } + + private async handleSendFailure( + sessionId: string, + rawText: string, + images: SelectedImageAttachment[], + err: Object + ): Promise { + this.stream.setRequestInFlight(false); + if (this.stream.takeRequestCancelled()) { + const finalMessage = this.stream.takePendingFinalMessage(); + if (finalMessage) { + await this.persistAssistantMessage(sessionId, finalMessage); + this.pageState.setServiceState(finalMessage.status === 'failed' ? + GeneralChatServiceState.Failed : GeneralChatServiceState.Ready); + } else { + this.pageState.setServiceState(GeneralChatServiceState.Ready); + } + this.resetStream(); + this.pageState.setBusy(false); + return; + } + this.flushDelta(sessionId); + const partialText = this.stream.text(); + const errorText = ConnectionErrorPolicy.errorText(err); + this.pageState.setServiceState(GeneralChatServiceStatus.fromError(errorText)); + this.resetStream(); + this.pageState.setChatInput(rawText); + this.pageState.setSelectedImages(images); + await this.drafts.saveNow(sessionId, rawText); + this.timeline.setPersistedMessages(await this.command.loadMessages(sessionId)); + if (partialText.trim().length > 0) { + const interrupted: ChatMessage = { + id: Encoding.randomId('assistant'), + turnId: this.hooks.currentActiveTurnId(), + role: 'assistant', + text: partialText, + status: 'failed', + detail: rawText, + timestamp: new Date().toISOString(), + items: this.assistantItems([], partialText) + }; + this.timeline.dispatch({ + type: 'turn_finished', + sessionId, + turnId: interrupted.turnId || this.hooks.currentActiveTurnId(), + message: interrupted + }); + await this.persistAssistantMessage(sessionId, interrupted); + } else { + this.timeline.dispatch({ type: 'error', message: errorText }); + } + this.hooks.syncTimeline(); + this.pageState.setStatus(errorText); + this.pageState.setBusy(false); + } + + private resetStream(): void { + this.stream.reset(); + } + + private async persistAssistantMessage(sessionId: string, message: ChatMessage): Promise { + if (sessionId.length > 0) { + await this.command.recordAssistantMessage(sessionId, message); + } + } + + private localUserMessage(text: string, images: SelectedImageAttachment[]): ChatMessage { + return { + id: Encoding.randomId('user'), + turnId: '', + role: 'user', + text, + status: 'sent', + detail: '', + timestamp: new Date().toISOString(), + images, + items: [] + }; + } + + private assistantItems(tools: RemoteToolStatusResponse[], text: string): ChatMessageItemResponse[] { + const items: ChatMessageItemResponse[] = []; + tools.forEach((tool: RemoteToolStatusResponse) => items.push({ type: 'tool', tool })); + if (text.trim().length > 0) { + items.push({ type: 'text', content: text }); + } + return items; + } + + private shortId(value: string): string { + return value.length > 8 ? value.slice(0, 8) : value; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets new file mode 100644 index 0000000000..fd029d22ec --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets @@ -0,0 +1,163 @@ +import { SessionSummary } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; +import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; + +export class RemoteActivityViewModelHooks { + readonly isConnected: () => boolean; + readonly isBusy: () => boolean; + readonly hasRemoteBinding: () => boolean; + readonly isRemoteChat: () => boolean; + readonly activeSession: () => SessionSummary; + readonly onConnectionState: (state: string) => void; + readonly onStatus: (status: string) => void; + readonly onConnectionError: (err: Object) => Promise; + readonly onStopHeartbeat: () => void; + readonly onStartPolling: () => void; + readonly onStopPolling: () => void; + readonly onPoll: () => Promise; + readonly onReconnect: () => Promise; + readonly onRestoreSession: (session: SessionSummary) => Promise; + + constructor( + isConnected: () => boolean, + isBusy: () => boolean, + hasRemoteBinding: () => boolean, + isRemoteChat: () => boolean, + activeSession: () => SessionSummary, + onConnectionState: (state: string) => void, + onStatus: (status: string) => void, + onConnectionError: (err: Object) => Promise, + onStopHeartbeat: () => void, + onStartPolling: () => void, + onStopPolling: () => void, + onPoll: () => Promise, + onReconnect: () => Promise, + onRestoreSession: (session: SessionSummary) => Promise + ) { + this.isConnected = isConnected; + this.isBusy = isBusy; + this.hasRemoteBinding = hasRemoteBinding; + this.isRemoteChat = isRemoteChat; + this.activeSession = activeSession; + this.onConnectionState = onConnectionState; + this.onStatus = onStatus; + this.onConnectionError = onConnectionError; + this.onStopHeartbeat = onStopHeartbeat; + this.onStartPolling = onStartPolling; + this.onStopPolling = onStopPolling; + this.onPoll = onPoll; + this.onReconnect = onReconnect; + this.onRestoreSession = onRestoreSession; + } +} + +/** Owns foreground recovery, heartbeat health checks, and idempotent resume cancellation. */ +export class RemoteActivityViewModel { + private readonly activity: RemoteActivityLifecycleController; + private readonly connection: RemoteConnectionCoordinator; + private readonly gate: AsyncLifecycleGate; + private readonly hooks: RemoteActivityViewModelHooks; + + constructor( + activity: RemoteActivityLifecycleController, + connection: RemoteConnectionCoordinator, + gate: AsyncLifecycleGate, + hooks: RemoteActivityViewModelHooks + ) { + this.activity = activity; + this.connection = connection; + this.gate = gate; + this.hooks = hooks; + } + + startHeartbeat(): void { + this.activity.startHeartbeat(); + } + + stopHeartbeat(): void { + this.activity.stopHeartbeat(); + } + + async checkConnectionHealth(): Promise { + if (!this.hooks.isConnected() || this.hooks.isBusy()) { + return; + } + try { + await this.connection.ping(); + } catch (err) { + if (await this.hooks.onConnectionError(err)) { + return; + } + const message = ConnectionErrorPolicy.errorText(err); + this.hooks.onConnectionState('failed'); + this.hooks.onStatus(message); + this.hooks.onStopPolling(); + this.hooks.onStopHeartbeat(); + } + } + + resume(): void { + if (!this.hooks.hasRemoteBinding()) { + return; + } + this.startHeartbeat(); + if (this.hooks.isRemoteChat() && this.hooks.activeSession().sessionId.length > 0) { + this.hooks.onStartPolling(); + void this.hooks.onPoll(); + } + void this.verifyForeground(); + } + + async verifyForeground(): Promise { + if (this.hooks.isBusy() || !this.hooks.hasRemoteBinding()) { + return; + } + const token = this.gate.begin(); + try { + this.hooks.onConnectionState('reconnecting'); + await this.connection.ping(); + if (!this.gate.isCurrent(token)) { + return; + } + this.hooks.onConnectionState('connected'); + this.hooks.onStatus(RemoteI18n.t('connection.connected')); + if (this.hooks.isRemoteChat() && this.hooks.activeSession().sessionId.length > 0) { + await this.hooks.onPoll(); + } + } catch (err) { + if (!this.gate.isCurrent(token)) { + return; + } + if (await this.hooks.onConnectionError(err)) { + return; + } + this.hooks.onStatus(ConnectionErrorPolicy.errorText(err)); + this.hooks.onStopHeartbeat(); + this.hooks.onConnectionState('reconnecting'); + await this.reconnect(token); + } + } + + invalidate(): void { + this.gate.invalidate(); + this.hooks.onStopPolling(); + this.stopHeartbeat(); + } + + private async reconnect(token: number): Promise { + const shouldRestore = this.hooks.isRemoteChat() && this.hooks.activeSession().sessionId.length > 0; + const session = this.hooks.activeSession(); + await this.hooks.onReconnect(); + if (!this.gate.isCurrent(token) || !this.hooks.isConnected()) { + return; + } + if (shouldRestore) { + await this.hooks.onRestoreSession(session); + this.hooks.onStartPolling(); + await this.hooks.onPoll(); + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets new file mode 100644 index 0000000000..9557e6a22e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets @@ -0,0 +1,349 @@ +import { + InitialSyncResult, + WorkspaceInfo +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ConnectionErrorResult } from '../../services/ConnectionErrorPolicy'; +import { ClipboardService } from '../../services/ClipboardService'; +import { Encoding } from '../../services/Encoding'; +import { MobileIdentitySnapshot, MobileIdentityStore } from '../../services/MobileIdentityStore'; +import { RemoteDescriptorParser } from '../../services/RemoteDescriptorParser'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { QrScanService } from '../../services/QrScanService'; +import { RemoteConnectionCoordinator, RemoteConnectionRequest } from '../../services/RemoteConnectionCoordinator'; +import { RemotePageState } from './RemotePageState'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { RemoteLogger } from '../../services/RemoteLogger'; + +export enum RemoteConnectionState { + Idle = 'idle', + Parsing = 'parsing', + Pairing = 'pairing', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed', + Disconnected = 'disconnected' +} + +export class RemoteConnectionViewModel { + private readonly pageState: RemotePageState; + private readonly identity: MobileIdentityStore; + private readonly pairing: RemotePairingPolicy; + private readonly connection: RemoteConnectionCoordinator; + private readonly sessions: RemoteSessionController; + private readonly models: RemoteModelController; + private readonly files: RemoteFileDownloadController; + private readonly clipboard: ClipboardService; + private readonly scanner: QrScanService; + private readonly resetTimeline: (sessionId: string) => void; + private readonly resetKnownState: () => void; + private readonly startHeartbeat: () => void; + private readonly stopHeartbeat: () => void; + private readonly stopPolling: () => void; + private readonly loadRecentWorkspaces: () => Promise; + private readonly replaceRoute: (route: AppRoute) => void; + private readonly closeConnectSheet: () => void; + private readonly openConnectSheet: () => void; + private deviceId: string = Encoding.randomId('harmony'); + private autoReconnectAttempted: boolean = false; + private failureCount: number = 0; + private lockUntil: number = 0; + + constructor( + pageState: RemotePageState, + identity: MobileIdentityStore, + pairing: RemotePairingPolicy, + connection: RemoteConnectionCoordinator, + sessions: RemoteSessionController, + models: RemoteModelController, + files: RemoteFileDownloadController, + clipboard: ClipboardService, + scanner: QrScanService, + resetTimeline: (sessionId: string) => void, + resetKnownState: () => void, + startHeartbeat: () => void, + stopHeartbeat: () => void, + stopPolling: () => void, + loadRecentWorkspaces: () => Promise, + replaceRoute: (route: AppRoute) => void, + closeConnectSheet: () => void, + openConnectSheet: () => void + ) { + this.pageState = pageState; + this.identity = identity; + this.pairing = pairing; + this.connection = connection; + this.sessions = sessions; + this.models = models; + this.files = files; + this.clipboard = clipboard; + this.scanner = scanner; + this.resetTimeline = resetTimeline; + this.resetKnownState = resetKnownState; + this.startHeartbeat = startHeartbeat; + this.stopHeartbeat = stopHeartbeat; + this.stopPolling = stopPolling; + this.loadRecentWorkspaces = loadRecentWorkspaces; + this.replaceRoute = replaceRoute; + this.closeConnectSheet = closeConnectSheet; + this.openConnectSheet = openConnectSheet; + } + + getDeviceId(): string { + return this.deviceId; + } + + async restore(context: Context): Promise { + try { + const snapshot: MobileIdentitySnapshot = await this.identity.init(context); + this.deviceId = snapshot.installId; + this.pageState.setUserId(snapshot.userId || snapshot.installId); + this.pageState.setRemoteUrl(snapshot.remoteUrl); + this.applyPairingProjection(snapshot.remoteUrl); + this.models.setPreferredModelId(await this.identity.getLastModelId()); + this.failureCount = snapshot.userIdFailureCount; + this.lockUntil = snapshot.userIdLockUntil > Date.now() ? snapshot.userIdLockUntil : 0; + if (snapshot.userIdLockUntil > 0 && this.lockUntil === 0) { + this.failureCount = 0; + await this.identity.clearUserIdProtection(); + } + this.pageState.setRemoteUrlInputVisible(snapshot.remoteUrl.length > 0); + if (this.shouldAutoReconnect()) { + this.autoReconnectAttempted = true; + this.setState(RemoteConnectionState.Reconnecting); + this.pageState.setStatusText(RemoteI18n.t('status.restoringConnection')); + await this.connect(true); + } + } catch (err) { + this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.setState(RemoteConnectionState.Failed); + } + } + + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { + if (this.pageState.isBusy) { + return; + } + try { + RemoteLogger.info(`connect start auto=${autoReconnect ? '1' : '0'}`); + this.pageState.setBusy(true); + this.pageState.setConnectionFailureKind(''); + if (this.lockUntil > 0 && !ConnectionErrorPolicy.isLocked(this.lockUntil)) { + this.failureCount = 0; + this.lockUntil = 0; + await this.identity.clearUserIdProtection(); + } + if (!autoReconnect && ConnectionErrorPolicy.isLocked(this.lockUntil)) { + throw new Error(RemoteI18n.f('errors.tooManyAttempts', `${ConnectionErrorPolicy.remainingLockSeconds(this.lockUntil)}`)); + } + this.setState(this.pageState.connectionState === RemoteConnectionState.Reconnecting ? + RemoteConnectionState.Reconnecting : RemoteConnectionState.Parsing); + this.pageState.setStatusText(RemoteI18n.t('status.parsingUrl')); + this.applyPairingProjection(this.pageState.remoteUrl); + this.setState(RemoteConnectionState.Pairing); + this.pageState.setStatusText(RemoteI18n.t('status.pairing')); + const request: RemoteConnectionRequest = { + remoteUrl: this.pageState.remoteUrl, + userId: this.pageState.userId, + deviceId: this.deviceId, + accountPassword, + autoReconnect + }; + const initialSync: InitialSyncResult = await this.connection.connect(request); + this.failureCount = 0; + this.lockUntil = 0; + this.applyWorkspace(initialSync.workspace); + this.pageState.setAuthenticatedUserId(initialSync.authenticatedUserId); + this.pageState.setControlTarget('room', this.pageState.desktopId, this.pageState.desktopName); + this.sessions.setSessions(initialSync.sessions, initialSync.hasMoreSessions); + this.setState(RemoteConnectionState.Connected); + this.pageState.setConnectionFailureKind(''); + this.pageState.setStatusText(RemoteI18n.t('connection.connected')); + this.closeConnectSheet(); + this.startHeartbeat(); + void this.loadRecentWorkspaces(); + RemoteLogger.info('connect success'); + } catch (err) { + if (!this.connection.isCurrentRequest()) { + return; + } + const result: ConnectionErrorResult = await ConnectionErrorPolicy.connectionErrorResult(err, autoReconnect, { + failureCount: this.failureCount, + lockUntil: this.lockUntil + }, this.identity); + if (!this.connection.isCurrentRequest()) { + return; + } + this.pageState.setStatusText(result.message); + this.pageState.setConnectionFailureKind(result.failureKind); + this.failureCount = result.failureCount; + this.lockUntil = result.lockUntil; + if (result.shouldShowRemoteUrlInput) { + this.pageState.setRemoteUrlInputVisible(true); + } + this.setState(RemoteConnectionState.Failed); + RemoteLogger.error(`connect failed stage=connection message=${ConnectionErrorPolicy.errorText(err)}`); + if (!autoReconnect) { + this.openConnectSheet(); + } + } finally { + if (this.connection.isCurrentRequest()) { + this.pageState.setBusy(false); + } + } + } + + async reconnect(): Promise { + if (this.pageState.isBusy) { + return; + } + this.setState(RemoteConnectionState.Reconnecting); + this.pageState.setStatusText(RemoteI18n.t('status.reconnecting')); + await this.connect(); + } + + async disconnect(clearPairing: boolean): Promise { + this.connection.invalidate(); + this.stopPolling(); + this.stopHeartbeat(); + this.connection.reset(); + this.sessions.clearSessions(); + this.pageState.clear(); + this.resetTimeline(''); + this.pageState.setHasMoreMessages(false); + this.models.clearCatalog(); + this.pageState.clearActiveSession(); + this.resetKnownState(); + this.files.clear(); + this.pageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + this.pageState.setAuthenticatedUserId(''); + this.pageState.clearControlTarget(); + this.pageState.clearWorkspaceActions(); + this.setState(RemoteConnectionState.Disconnected); + this.pageState.setConnectionFailureKind(''); + this.pageState.setStatusText(clearPairing ? RemoteI18n.t('status.pairingCleared') : RemoteI18n.t('status.disconnected')); + this.replaceRoute(AppRoute.RemoteHome); + if (clearPairing) { + this.pageState.setRemoteUrl(''); + this.pageState.setUserId(this.deviceId); + this.pageState.setAccountPairing(false, ''); + this.pageState.setRemoteUrlInputVisible(false); + await this.identity.clearPairingInput(); + } else { + this.pageState.setRemoteUrlInputVisible(this.pageState.remoteUrl.trim().length > 0); + } + } + + async paste(): Promise { + try { + this.pageState.setStatusText(RemoteI18n.t('status.readClipboard')); + const text = (await this.clipboard.readText()).trim(); + if (text.length === 0) { + this.pageState.setRemoteUrlInputVisible(true); + this.pageState.setStatusText(RemoteI18n.t('status.clipboardEmpty')); + return; + } + this.applyRemoteUrl(text); + try { + RemoteDescriptorParser.parse(text); + this.pageState.setStatusText(RemoteI18n.t('status.clipboardUrlFilled')); + } catch (_err) { + this.pageState.setStatusText(RemoteI18n.t('status.clipboardNeedsConfirm')); + } + } catch (err) { + this.pageState.setRemoteUrlInputVisible(true); + this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + async scan(context: Context): Promise { + try { + this.pageState.setStatusText(RemoteI18n.t('status.openScanner')); + const text = (await this.scanner.scanRemoteUrl(context)).trim(); + if (text.length === 0) { + this.pageState.setStatusText(RemoteI18n.t('status.scanEmpty')); + return; + } + this.handleDetectedUrl(text); + } catch (err) { + this.pageState.setRemoteUrlInputVisible(true); + this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + handleDetectedUrl(remoteUrl: string): boolean { + try { + this.applyRemoteUrl(remoteUrl); + const descriptor = RemoteDescriptorParser.parse(remoteUrl); + this.pageState.setStatusText(RemoteI18n.t('status.scannedUrl')); + if (this.pairing.shouldPromptForAccount(descriptor)) { + this.pageState.setStatusText(RemoteI18n.t('connect.enterAccountToPair')); + this.openConnectSheet(); + return true; + } + this.closeConnectSheet(); + this.replaceRoute(AppRoute.RemoteHome); + void this.connect(); + return false; + } catch (err) { + this.pageState.setRemoteUrlInputVisible(true); + this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + return true; + } + } + + ensureAvailable(): boolean { + if (RemoteUiState.canUseRemote(this.pageState.connectionState)) { + return true; + } + this.pageState.setStatusText(RemoteI18n.t('status.remoteUnavailable')); + return false; + } + + projectRemoteUrl(remoteUrl: string): void { + this.applyPairingProjection(remoteUrl); + } + + applyWorkspace(workspace: WorkspaceInfo): void { + this.pageState.setWorkspace(workspace.name, workspace.path, workspace.assistantId || '', workspace.gitBranch, + workspace.workspaceKind || 'normal'); + } + + private applyRemoteUrl(remoteUrl: string): void { + this.pageState.setRemoteUrl(remoteUrl); + this.applyPairingProjection(remoteUrl); + this.pageState.setConnectionFailureKind(''); + this.pageState.setRemoteUrlInputVisible(true); + } + + private applyPairingProjection(remoteUrl: string): void { + this.pageState.setDesktopIdentity( + remoteUrl.trim().length > 0 ? RemoteUiState.desktopNameFromRemoteUrl(remoteUrl) : this.pageState.desktopName, + remoteUrl.trim().length > 0 ? RemoteUiState.desktopIdFromRemoteUrl(remoteUrl) : '' + ); + const projection = this.pairing.projection(remoteUrl); + this.pageState.setAccountPairing(projection.requiresAccountAuth, projection.accountUsername); + const projectedUserId = this.pairing.userIdAfterProjection(this.pageState.userId, this.deviceId, projection); + if (projectedUserId !== this.pageState.userId) { + this.pageState.setUserId(projectedUserId); + } + } + + private shouldAutoReconnect(): boolean { + return this.pairing.shouldAutoReconnect({ + autoReconnectAttempted: this.autoReconnectAttempted, + remoteUrl: this.pageState.remoteUrl, + userId: this.pageState.userId, + requiresAccountAuth: this.pageState.requiresAccountAuth + }); + } + + private setState(state: RemoteConnectionState): void { + this.pageState.setConnectionState(state); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets new file mode 100644 index 0000000000..dd42dad127 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -0,0 +1,76 @@ +import { RecentWorkspaceEntry } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; + +@ObservedV2 +export class RemoteCreateSessionState { + @Trace draft: string = ''; + @Trace devices: CloudAccountDevice[] = []; + @Trace workspaces: RecentWorkspaceEntry[] = []; + @Trace selectedDeviceId: string = ''; + @Trace selectedDeviceName: string = ''; + @Trace selectedWorkspacePath: string = ''; + @Trace selectedWorkspaceName: string = ''; + @Trace openMenu: string = 'none'; + @Trace isLoadingDevices: boolean = false; + @Trace isLoadingWorkspaces: boolean = false; + @Trace isSubmitting: boolean = false; + @Trace errorText: string = ''; + + prepare(deviceId: string, deviceName: string): void { + this.draft = ''; + this.devices = []; + this.workspaces = []; + this.selectedDeviceId = deviceId; + this.selectedDeviceName = deviceName; + this.selectedWorkspacePath = ''; + this.selectedWorkspaceName = ''; + this.openMenu = 'none'; + this.isLoadingDevices = false; + this.isLoadingWorkspaces = false; + this.isSubmitting = false; + this.errorText = ''; + } + + setDraft(draft: string): void { + this.draft = draft; + this.errorText = ''; + } + + setDevices(devices: CloudAccountDevice[]): void { + this.devices = devices.slice(); + this.isLoadingDevices = false; + } + + setWorkspaces(workspaces: RecentWorkspaceEntry[]): void { + this.workspaces = workspaces.slice(); + this.isLoadingWorkspaces = false; + } + + selectDevice(device: CloudAccountDevice): void { + this.selectedDeviceId = device.deviceId; + this.selectedDeviceName = device.deviceName; + this.clearWorkspace(); + this.openMenu = 'none'; + this.errorText = ''; + } + + selectWorkspace(workspace?: RecentWorkspaceEntry): void { + this.selectedWorkspacePath = workspace?.path || ''; + this.selectedWorkspaceName = workspace?.name || ''; + this.openMenu = 'none'; + this.errorText = ''; + } + + clearWorkspace(): void { + this.selectedWorkspacePath = ''; + this.selectedWorkspaceName = ''; + } + + toggleMenu(menu: string): void { + this.openMenu = this.openMenu === menu ? 'none' : menu; + } + + closeMenu(): void { + this.openMenu = 'none'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index bd79afb213..c8143a6ba0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -23,7 +23,11 @@ export class RemotePageState { @Trace userId: string = ''; @Trace requiresAccountAuth: boolean = false; @Trace accountUsername: string = ''; + @Trace accountUserId: string = ''; @Trace authenticatedUserId: string = ''; + @Trace controlTargetType: string = 'none'; + @Trace controlTargetDeviceId: string = ''; + @Trace controlTargetDeviceName: string = ''; @Trace statusText: string = ''; @Trace connectionState: string = 'idle'; @Trace connectionFailureKind: string = ''; @@ -55,6 +59,7 @@ export class RemotePageState { @Trace selectedImages: SelectedImageAttachment[] = []; @Trace isVoiceListening: boolean = false; @Trace sessionQuery: string = ''; + @Trace sessionFilter: string = 'all'; @Trace hasMoreSessions: boolean = false; @Trace isLoadingSessions: boolean = false; @Trace sessionErrorText: string = ''; @@ -64,6 +69,10 @@ export class RemotePageState { this.sessionQuery = query; } + setFilter(filter: string): void { + this.sessionFilter = filter; + } + setDesktopName(desktopName: string): void { this.desktopName = desktopName; } @@ -84,6 +93,24 @@ export class RemotePageState { this.accountUsername = accountUsername; } + setAccountUsername(accountUsername: string): void { + this.accountUsername = accountUsername; + } + + setAccountUserId(accountUserId: string): void { + this.accountUserId = accountUserId; + } + + setControlTarget(type: string, deviceId: string, deviceName: string): void { + this.controlTargetType = type; + this.controlTargetDeviceId = deviceId; + this.controlTargetDeviceName = deviceName; + } + + clearControlTarget(): void { + this.setControlTarget('none', '', ''); + } + setRemoteUrl(remoteUrl: string): void { this.remoteUrl = remoteUrl; } @@ -190,6 +217,10 @@ export class RemotePageState { this.timelineItems = timelineItems.slice(); } + setHasMoreMessages(hasMoreMessages: boolean): void { + this.hasMoreMessages = hasMoreMessages; + } + clearTimeline(): void { this.persistedMessages = []; this.optimisticMessages = []; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets new file mode 100644 index 0000000000..afba61c242 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets @@ -0,0 +1,224 @@ +import { RemoteSession, SessionSummary } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemotePageState } from './RemotePageState'; + +export class RemoteSessionViewModelHooks { + readonly remoteAvailable: () => boolean; + readonly isConnected: () => boolean; + readonly isBusy: () => boolean; + readonly onBusy: (busy: boolean) => void; + readonly onRouteChat: (sessionId: string) => void; + readonly onRouteHome: () => void; + readonly onStopPolling: () => void; + readonly onStartPolling: () => void; + readonly onResetTimeline: (sessionId: string) => void; + readonly onClearRemoteFiles: () => void; + readonly onKnownStateReset: () => void; + readonly onLoadModelCatalog: (sessionId: string) => Promise; + readonly onLoadActiveMessages: () => Promise; + readonly onRefreshSessions: () => Promise; + readonly onSelectWorkspace: (path: string) => Promise; + + constructor( + remoteAvailable: () => boolean, + isConnected: () => boolean, + isBusy: () => boolean, + onBusy: (busy: boolean) => void, + onRouteChat: (sessionId: string) => void, + onRouteHome: () => void, + onStopPolling: () => void, + onStartPolling: () => void, + onResetTimeline: (sessionId: string) => void, + onClearRemoteFiles: () => void, + onKnownStateReset: () => void, + onLoadModelCatalog: (sessionId: string) => Promise, + onLoadActiveMessages: () => Promise, + onRefreshSessions: () => Promise, + onSelectWorkspace: (path: string) => Promise + ) { + this.remoteAvailable = remoteAvailable; + this.isConnected = isConnected; + this.isBusy = isBusy; + this.onBusy = onBusy; + this.onRouteChat = onRouteChat; + this.onRouteHome = onRouteHome; + this.onStopPolling = onStopPolling; + this.onStartPolling = onStartPolling; + this.onResetTimeline = onResetTimeline; + this.onClearRemoteFiles = onClearRemoteFiles; + this.onKnownStateReset = onKnownStateReset; + this.onLoadModelCatalog = onLoadModelCatalog; + this.onLoadActiveMessages = onLoadActiveMessages; + this.onRefreshSessions = onRefreshSessions; + this.onSelectWorkspace = onSelectWorkspace; + } +} + +/** Owns remote session commands and their page lifecycle effects. */ +export class RemoteSessionViewModel { + private readonly pageState: RemotePageState; + private readonly sessions: RemoteSessionController; + private readonly chat: RemoteChatCommandController; + private readonly models: RemoteModelController; + private readonly files: RemoteFileDownloadController; + private readonly hooks: RemoteSessionViewModelHooks; + + constructor( + pageState: RemotePageState, + sessions: RemoteSessionController, + chat: RemoteChatCommandController, + models: RemoteModelController, + files: RemoteFileDownloadController, + hooks: RemoteSessionViewModelHooks + ) { + this.pageState = pageState; + this.sessions = sessions; + this.chat = chat; + this.models = models; + this.files = files; + this.hooks = hooks; + } + + async refreshSessions(): Promise { + await this.sessions.refresh( + this.pageState.sessionQuery, + this.pageState.sessionFilter, + this.hooks.remoteAvailable(), + this.hooks.isConnected() + ); + } + + async loadMoreSessions(): Promise { + await this.sessions.loadMore( + this.pageState.sessionQuery, + this.pageState.sessionFilter, + this.hooks.isBusy(), + this.hooks.remoteAvailable() + ); + } + + async setFilter(filter: string): Promise { + if (this.pageState.sessionFilter === filter) { + return; + } + this.pageState.setFilter(filter); + await this.refreshSessions(); + } + + async createSession(agentType: string, instruction: string = ''): Promise { + await this.sessions.create( + agentType, + this.hooks.isBusy(), + this.hooks.remoteAvailable(), + async (session: SessionSummary): Promise => { + this.pageState.clearComposer(); + this.pageState.setHasMoreMessages(false); + this.hooks.onKnownStateReset(); + this.hooks.onResetTimeline(session.sessionId); + this.hooks.onRouteChat(session.sessionId); + await this.hooks.onLoadModelCatalog(session.sessionId); + await this.refreshSessions(); + await this.hooks.onLoadActiveMessages(); + this.hooks.onStartPolling(); + }, + instruction + ); + } + + async createSessionInWorkspace( + path: string, + currentPath: string, + instruction: string = '', + agentType: string = 'code' + ): Promise { + if (path.length > 0 && path !== currentPath) { + await this.hooks.onSelectWorkspace(path); + await this.createSession(agentType, instruction); + return; + } + if (path.length === 0 || path === currentPath) { + await this.createSession(agentType, instruction); + } + } + + async openSession(item: RemoteSession, currentWorkspacePath: string): Promise { + await this.sessions.open( + item, + item.workspacePath || currentWorkspacePath, + this.hooks.isBusy(), + this.hooks.remoteAvailable(), + async (session: SessionSummary): Promise => { + this.hooks.onStopPolling(); + this.hooks.onResetTimeline(item.id); + this.hooks.onKnownStateReset(); + this.pageState.setHasMoreMessages(false); + this.files.clear(); + this.pageState.clearComposer(); + this.hooks.onRouteChat(item.id); + await this.hooks.onLoadModelCatalog(item.id); + await this.hooks.onLoadActiveMessages(); + if (this.pageState.activeSession.sessionId === session.sessionId) { + this.hooks.onStartPolling(); + } + } + ); + } + + async deleteSession(item: RemoteSession, currentWorkspacePath: string): Promise { + await this.sessions.delete( + item, + this.pageState.activeSession.sessionId, + this.hooks.isBusy(), + this.hooks.remoteAvailable(), + this.pageState.sessionQuery, + this.pageState.sessionFilter, + async (): Promise => { + this.hooks.onStopPolling(); + this.hooks.onResetTimeline(''); + this.pageState.setHasMoreMessages(false); + this.pageState.clearComposer(); + this.models.clearCatalog(); + this.hooks.onKnownStateReset(); + this.pageState.setActiveSession({ + sessionId: '', + title: '', + workspacePath: currentWorkspacePath, + agentType: 'code' + }); + this.pageState.clearActiveSession(); + this.hooks.onRouteHome(); + } + ); + } + + async loadActiveMessages(isChatRoute: (sessionId: string) => boolean): Promise { + const sessionId = this.pageState.activeSession.sessionId || ''; + await this.chat.loadMessages(sessionId, isChatRoute); + } + + async loadOlderMessages(knownPollVersion: number): Promise { + await this.chat.loadOlderMessages( + this.pageState.activeSession.sessionId || '', + knownPollVersion, + this.pageState.hasMoreMessages, + this.hooks.isBusy() + ); + } + + async loadModelCatalog(sessionId: string, isChatRoute: (sessionId: string) => boolean): Promise { + await this.models.loadCatalog(sessionId, this.hooks.remoteAvailable(), isChatRoute); + } + + async selectModel(modelId: string): Promise { + await this.models.selectModel( + modelId, + this.pageState.activeSession.sessionId || '', + this.hooks.isBusy(), + this.hooks.remoteAvailable() + ); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets new file mode 100644 index 0000000000..87b9818dd8 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets @@ -0,0 +1,171 @@ +import { RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { RemotePageState } from './RemotePageState'; + +export class RemoteWorkspaceViewModelHooks { + readonly isRemoteAvailable: () => boolean; + readonly isBusy: () => boolean; + readonly onBusy: (isBusy: boolean) => void; + readonly onStatus: (statusText: string) => void; + readonly onWorkspaceSelected: (workspace: WorkspaceInfo) => void; + readonly onSessionsDiscovered: (sessions: RemoteSession[]) => void; + readonly onRefreshSessions: () => Promise; + readonly onConnectionFailure: (error: Object) => void; + + constructor( + isRemoteAvailable: () => boolean, + isBusy: () => boolean, + onBusy: (isBusy: boolean) => void, + onStatus: (statusText: string) => void, + onWorkspaceSelected: (workspace: WorkspaceInfo) => void, + onSessionsDiscovered: (sessions: RemoteSession[]) => void, + onRefreshSessions: () => Promise, + onConnectionFailure: (error: Object) => void + ) { + this.isRemoteAvailable = isRemoteAvailable; + this.isBusy = isBusy; + this.onBusy = onBusy; + this.onStatus = onStatus; + this.onWorkspaceSelected = onWorkspaceSelected; + this.onSessionsDiscovered = onSessionsDiscovered; + this.onRefreshSessions = onRefreshSessions; + this.onConnectionFailure = onConnectionFailure; + } +} + +/** Owns the workspace/assistant picker workflows and their presentation state. */ +export class RemoteWorkspaceViewModel { + private readonly pageState: RemotePageState; + private readonly coordinator: RemoteWorkspaceCoordinator; + private readonly hooks: RemoteWorkspaceViewModelHooks; + + constructor( + pageState: RemotePageState, + coordinator: RemoteWorkspaceCoordinator, + hooks: RemoteWorkspaceViewModelHooks + ) { + this.pageState = pageState; + this.coordinator = coordinator; + this.hooks = hooks; + } + + async toggleRecentWorkspaces(): Promise { + if (this.pageState.showWorkspacePicker) { + this.pageState.setWorkspacePickerVisible(false); + return; + } + this.pageState.setWorkspacePickerVisible(true); + this.pageState.setAssistantPickerVisible(false); + await this.loadRecentWorkspaces(); + } + + async toggleAssistants(): Promise { + if (this.pageState.showAssistantPicker) { + this.pageState.setAssistantPickerVisible(false); + return; + } + this.pageState.setAssistantPickerVisible(true); + this.pageState.setWorkspacePickerVisible(false); + await this.loadAssistants(); + } + + async selectWorkspace(path: string): Promise { + return await this.select(path, false); + } + + async selectAssistant(path: string): Promise { + return await this.select(path, true); + } + + async loadRecentWorkspacesInBackground(): Promise { + try { + const recent = await this.coordinator.recentWorkspaces(); + const assistants = await this.coordinator.assistants(); + const assistantWorkspaces: RecentWorkspaceEntry[] = []; + assistants.forEach((item) => { + assistantWorkspaces.push({ + path: item.path, + name: item.name, + lastOpened: '', + workspaceKind: 'assistant' + } as RecentWorkspaceEntry); + }); + const allWorkspaces = recent.slice(); + assistantWorkspaces.forEach((item: RecentWorkspaceEntry) => { + if (!allWorkspaces.some((existing: RecentWorkspaceEntry) => existing.path === item.path)) { + allWorkspaces.push(item); + } + }); + this.pageState.setRecentWorkspaces(allWorkspaces); + const sessions = await this.coordinator.sessionsForWorkspaces( + allWorkspaces.map((item: RecentWorkspaceEntry) => item.path) + ); + this.hooks.onSessionsDiscovered(sessions); + } catch (err) { + RemoteLogger.warn(`background recent workspace load failed: ${String(err)}`); + } + } + + private async loadRecentWorkspaces(): Promise { + if (!this.hooks.isRemoteAvailable()) { + return; + } + try { + this.hooks.onBusy(true); + this.hooks.onStatus(RemoteI18n.t('status.loadingRecentWorkspaces')); + const recent = await this.coordinator.recentWorkspaces(); + this.pageState.setRecentWorkspaces(recent); + this.hooks.onStatus(recent.length > 0 ? + RemoteI18n.t('status.chooseWorkspace') : RemoteI18n.t('status.noRecentWorkspaces')); + } catch (err) { + this.hooks.onConnectionFailure(err); + } finally { + this.hooks.onBusy(false); + } + } + + private async loadAssistants(): Promise { + if (!this.hooks.isRemoteAvailable()) { + return; + } + try { + this.hooks.onBusy(true); + this.hooks.onStatus(RemoteI18n.t('status.loadingAssistants')); + const assistants = await this.coordinator.assistants(); + this.pageState.setAssistants(assistants); + this.hooks.onStatus(assistants.length > 0 ? + RemoteI18n.t('status.chooseAssistant') : RemoteI18n.t('status.noAssistants')); + } catch (err) { + this.hooks.onConnectionFailure(err); + } finally { + this.hooks.onBusy(false); + } + } + + private async select(path: string, assistant: boolean): Promise { + if (this.hooks.isBusy() || path.trim().length === 0 || !this.hooks.isRemoteAvailable()) { + return false; + } + try { + this.hooks.onBusy(true); + this.hooks.onStatus(RemoteI18n.t(assistant ? + 'status.switchingAssistant' : 'status.switchingWorkspace')); + const workspace = assistant ? + await this.coordinator.switchAssistant(path) : await this.coordinator.switchWorkspace(path); + this.hooks.onWorkspaceSelected(workspace); + this.pageState.closePickers(); + this.pageState.clear(); + this.hooks.onStatus(RemoteI18n.t(assistant ? + 'status.assistantSwitched' : 'status.workspaceSwitched')); + this.hooks.onBusy(false); + await this.hooks.onRefreshSessions(); + return true; + } catch (err) { + this.hooks.onConnectionFailure(err); + this.hooks.onBusy(false); + return false; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AsyncLifecycleGate.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AsyncLifecycleGate.ets new file mode 100644 index 0000000000..ce1daa021a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AsyncLifecycleGate.ets @@ -0,0 +1,20 @@ +/** + * Invalidates asynchronous work when the owning UI lifecycle changes. + * A token is intentionally cheap to copy and can be checked after every await. + */ +export class AsyncLifecycleGate { + private generation: number = 0; + + begin(): number { + this.generation += 1; + return this.generation; + } + + invalidate(): void { + this.generation += 1; + } + + isCurrent(token: number): boolean { + return token === this.generation; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index 2245149d52..84837768fa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets @@ -45,6 +45,8 @@ export class ChatSessionController { private timerId: number = 0; private polling: boolean = false; private stopped: boolean = true; + // Invalidates in-flight requests when the session or lifecycle changes. + private generation: number = 0; private hasActiveRunningTurn: boolean = false; private turnJustEndedAt: number = 0; private readonly activeIntervalMs: number = 350; @@ -74,6 +76,7 @@ export class ChatSessionController { } stop(clearActiveTurn: boolean = true): void { + this.generation += 1; if (this.timerId !== 0) { clearTimeout(this.timerId); this.timerId = 0; @@ -135,49 +138,66 @@ export class ChatSessionController { return; } + const requestGeneration = this.generation; + const requestSessionId = this.sessionId; + const requestCursor: ChatSessionCursor = { + pollVersion: this.cursor.pollVersion, + knownMessageCount: this.cursor.knownMessageCount, + knownModelCatalogVersion: this.cursor.knownModelCatalogVersion + }; this.polling = true; try { - RemoteLogger.info(`poller tick session=${ChatSessionController.shortId(this.sessionId)} version=${this.cursor.pollVersion} known=${this.cursor.knownMessageCount}`); + RemoteLogger.info(`poller tick session=${ChatSessionController.shortId(requestSessionId)} version=${requestCursor.pollVersion} known=${requestCursor.knownMessageCount}`); const result = await this.sessionManager.pollSession( - this.sessionId, - this.cursor.pollVersion, - this.cursor.knownMessageCount, - this.cursor.knownModelCatalogVersion + requestSessionId, + requestCursor.pollVersion, + requestCursor.knownMessageCount, + requestCursor.knownModelCatalogVersion ); + if (!this.isCurrentRequest(requestGeneration, requestSessionId)) { + return; + } this.applyPollResult(result); } catch (err) { - this.callbacks.onError(err); + if (this.isCurrentRequest(requestGeneration, requestSessionId)) { + this.callbacks.onError(err); + } } finally { - this.polling = false; - this.scheduleNext(); + if (this.isCurrentRequest(requestGeneration, requestSessionId)) { + this.polling = false; + this.scheduleNext(); + } } } - private applyPollResult(result: PollSessionResult): void { - if (!result.changed) { - this.emitSnapshot(false, result, [], false); - return; - } + private isCurrentRequest(requestGeneration: number, requestSessionId: string): boolean { + return requestGeneration === this.generation && + !this.stopped && + requestSessionId === this.sessionId; + } + private applyPollResult(result: PollSessionResult): void { const hadRunningTurn = this.hasActiveRunningTurn; const incomingMessages = result.newMessages || []; const hasAssistantMessage = incomingMessages.some((message: ChatMessage) => { return message.role === 'assistant'; }); - this.cursor = { - pollVersion: result.version, - knownMessageCount: result.totalMessageCount > 0 - ? result.totalMessageCount - : this.cursor.knownMessageCount, - knownModelCatalogVersion: result.modelCatalog - ? (result.modelCatalog.version || 0) - : this.cursor.knownModelCatalogVersion - }; + if (result.changed) { + this.cursor = { + pollVersion: result.version, + knownMessageCount: result.totalMessageCount > 0 + ? result.totalMessageCount + : this.cursor.knownMessageCount, + knownModelCatalogVersion: result.modelCatalog + ? (result.modelCatalog.version || 0) + : this.cursor.knownModelCatalogVersion + }; + } if (result.activeTurn && result.activeTurn.id.length > 0) { this.activeTurn = result.activeTurn; - } else if (hasAssistantMessage || this.shouldClearMissingActiveTurn(result)) { + } else if (result.changed && (hasAssistantMessage || this.shouldClearMissingActiveTurn(result))) { this.activeTurn = undefined; } @@ -190,7 +210,13 @@ export class ChatSessionController { const isSettlingEndedTurn = this.turnJustEndedAt > 0 && Date.now() - this.turnJustEndedAt < this.turnEndedGracePeriodMs; - this.emitSnapshot(true, result, incomingMessages, turnEndedNow || (isSettlingEndedTurn && !isRunningNow)); + const activeTurnChanged = result.activeTurn !== undefined; + this.emitSnapshot( + result.changed || activeTurnChanged, + result, + incomingMessages, + turnEndedNow || (isSettlingEndedTurn && !isRunningNow) + ); } private shouldClearMissingActiveTurn(result: PollSessionResult): boolean { @@ -234,8 +260,13 @@ export class ChatSessionController { this.timerId = 0; } const nextDelay = delayMs !== undefined ? delayMs : this.pollIntervalMs(); + const timerGeneration = this.generation; + const timerSessionId = this.sessionId; RemoteLogger.info(`poller schedule session=${ChatSessionController.shortId(this.sessionId)} delay=${nextDelay}`); this.timerId = setTimeout(() => { + if (timerGeneration !== this.generation || timerSessionId !== this.sessionId || this.stopped) { + return; + } this.timerId = 0; this.pollNow(); }, nextDelay); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets index 2016b039e0..691a619a49 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets @@ -8,6 +8,7 @@ import { import { ChatSessionCursor, ChatSessionSnapshot } from './ChatSessionController'; import { ChatTimelineItem, ChatTimelineProjector } from './ChatTimelineProjector'; import { RemoteUiState } from './RemoteUiState'; +import { ConversationEvent } from './ConversationEvent'; export type ChatSyncPhase = | 'idle' @@ -314,6 +315,80 @@ export class ChatTimelineStore { } } + /** Applies transport-neutral conversation events from local or remote adapters. */ + applyEvent(event: ConversationEvent): void { + switch (event.type) { + case 'user_message': + if (event.persisted) { + this.mergePersistedMessages([event.message]); + } else { + this.appendOptimisticMessage(event.message); + } + return; + case 'turn_started': + if (this.acceptsSession(event.sessionId)) { + this.setLocalActiveTurn(event.turnId); + } + return; + case 'assistant_delta': + if (!this.acceptsSession(event.sessionId)) { + return; + } + this.appendAssistantDelta(event.turnId, event.delta); + return; + case 'assistant_message': + this.mergePersistedMessages([event.message]); + this.clearActiveTurn(); + return; + case 'active_turn_updated': + if (this.acceptsSession(event.sessionId)) { + this.setActiveTurn(event.message); + } + return; + case 'tool_started': + if (this.acceptsSession(event.sessionId)) { + this.updateActiveTool(event.turnId, event.tool); + } + return; + case 'tool_finished': + if (this.acceptsSession(event.sessionId)) { + this.updateActiveTool(event.turnId, event.tool); + } + return; + case 'turn_finished': + if (event.message) { + this.mergePersistedMessages([event.message]); + this.clearActiveTurn(); + } else if (this.acceptsSession(event.sessionId)) { + const active = this.state.activeTurn; + if (active && active.turnId === event.turnId) { + this.setActiveTurn({ + id: active.id, + turnId: active.turnId, + role: active.role, + text: active.text, + status: 'completed', + detail: active.detail, + timestamp: active.timestamp, + thinking: active.thinking, + tools: active.tools, + items: active.items, + images: active.images + }); + } + } + return; + case 'session_updated': + if (this.state.sessionId.length === 0 || this.state.sessionId === event.session.sessionId) { + return; + } + return; + case 'error': + this.setSyncPhase('error'); + return; + } + } + activeTurnOrEmpty(): ChatMessage { return this.state.activeTurn || RemoteUiState.emptyActiveTurn(); } @@ -327,6 +402,63 @@ export class ChatTimelineStore { ); } + private acceptsSession(sessionId: string): boolean { + return sessionId.length === 0 || this.state.sessionId.length === 0 || this.state.sessionId === sessionId; + } + + private appendAssistantDelta(turnId: string, delta: string): void { + if (delta.length === 0) { + return; + } + if (!this.state.activeTurn || this.state.activeTurn.turnId !== turnId) { + this.setLocalActiveTurn(turnId); + } + const active = this.state.activeTurn; + if (!active) { + return; + } + this.setActiveTurn({ + id: active.id, + turnId: active.turnId, + role: active.role, + text: `${active.text || ''}${delta}`, + status: 'active', + detail: active.detail, + timestamp: active.timestamp, + thinking: active.thinking, + tools: active.tools, + items: active.items, + images: active.images, + renderVersion: (active.renderVersion || 0) + delta.length + }); + } + + private updateActiveTool(turnId: string, tool: RemoteToolStatusResponse): void { + if (!this.state.activeTurn || this.state.activeTurn.turnId !== turnId) { + this.setLocalActiveTurn(turnId); + } + const active = this.state.activeTurn; + if (!active) { + return; + } + const tools = (active.tools || []).filter((item: RemoteToolStatusResponse) => item.id !== tool.id); + tools.push(tool); + this.setActiveTurn({ + id: active.id, + turnId: active.turnId, + role: active.role, + text: active.text, + status: 'active', + detail: active.detail, + timestamp: active.timestamp, + thinking: active.thinking, + tools, + items: active.items, + images: active.images, + renderVersion: (active.renderVersion || 0) + 1 + }); + } + static optimisticMessagesNotPersisted(optimisticMessages: ChatMessage[], persistedMessages: ChatMessage[]): ChatMessage[] { return optimisticMessages.filter((pending: ChatMessage) => { return !persistedMessages.some((message: ChatMessage) => { @@ -453,18 +585,31 @@ export class ChatTimelineStore { if (incomingItems.length === 0) { return previousItems; } - const merged: ChatMessageItemResponse[] = []; - incomingItems.forEach((incoming: ChatMessageItemResponse, index: number) => { - const previous = previousItems[index]; - if (previous && ChatTimelineStore.sameActiveItem(previous, incoming)) { - merged.push(ChatTimelineStore.mergeActiveItem(previous, incoming)); + // Poll snapshots can temporarily omit an item while the desktop turn is + // being assembled. Keep the existing order and merge tool rows by their + // stable id instead of treating the array index as identity. + const merged = previousItems.slice(); + const matched = new Set(); + incomingItems.forEach((incoming: ChatMessageItemResponse, incomingIndex: number) => { + let matchIndex = -1; + const incomingToolId = incoming.tool ? (incoming.tool.id || '') : ''; + if (incomingToolId.length > 0) { + matchIndex = previousItems.findIndex((previous: ChatMessageItemResponse, index: number) => { + return !matched.has(index) && previous.tool !== undefined && + (previous.tool.id || '') === incomingToolId; + }); + } else if (incomingIndex < previousItems.length && + !matched.has(incomingIndex) && + ChatTimelineStore.sameActiveItem(previousItems[incomingIndex], incoming)) { + matchIndex = incomingIndex; + } + if (matchIndex >= 0) { + merged[matchIndex] = ChatTimelineStore.mergeActiveItem(previousItems[matchIndex], incoming); + matched.add(matchIndex); } else { merged.push(incoming); } }); - for (let index = incomingItems.length; index < previousItems.length; index++) { - merged.push(previousItems[index]); - } return merged; } @@ -534,7 +679,28 @@ export class ChatTimelineStore { if (!incoming) { return previous; } - return incoming; + if (!previous) { + return incoming; + } + const previousStatus = (previous.status || '').toLowerCase(); + const incomingStatus = (incoming.status || '').toLowerCase(); + const rank = (status: string): number => { + if (status === 'pending_confirmation' || status === 'needs_confirmation' || status === 'pending') { + return 1; + } + if (status === 'running' || status === 'active') { + return 2; + } + if (status === 'completed' || status === 'success' || status === 'finished') { + return 3; + } + if (status === 'rejected' || status === 'cancelled' || status === 'canceled' || status === 'error' || status === 'failed') { + return 4; + } + return 0; + }; + // A delayed poll must not move a completed tool back to running/pending. + return rank(incomingStatus) < rank(previousStatus) ? previous : incoming; } private static isActiveTurnId(id: string): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets new file mode 100644 index 0000000000..cc5545af33 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -0,0 +1,287 @@ +import { http } from '@kit.NetworkKit'; +import { CloudAccountCrypto, CloudAccountKdfParams } from './CloudAccountCrypto'; +import { Encoding } from './Encoding'; +import { HarmonyRemoteCryptoCipher } from './RemoteCrypto'; +import { RemoteLogger } from './RemoteLogger'; +import { CommandStatusResponse, EncryptedPayload, RemoteCommand } from '../model/RemoteModels'; + +interface AccountChallenge { + salt: string; + kdf_salt: string; + argon2_params: string; + wrapped_master_key: string; +} + +interface AccountAuthResponse { + token: string; + user_id: string; +} + +interface LoginChallengeRequest { username: string; } +interface LoginRequest { username: string; password_hash: string; device_id: string; device_name: string; } +interface RelayErrorResponse { error?: string; } + +export class CloudAccountRequestError extends Error { + readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = 'CloudAccountRequestError'; + this.statusCode = statusCode; + } +} + +export interface CloudAccountSession { + token: string; + userId: string; + masterKey: Uint8Array; +} + +export interface CloudAccountDevice { + deviceId: string; + deviceName: string; + online: boolean; + lastSeenAt?: number; +} + +interface CloudAccountDeviceWire { + device_id: string; + device_name: string; + online: boolean; + last_seen_at?: number; +} + +export interface CloudSessionBundle { + sessionId: string; + metadata: Object; + turns: Object[]; + sourceDeviceId?: string; + sourceDeviceName?: string; + version: number; +} + +interface SyncSessionEntry { + session_id: string; + encrypted_data: string; + nonce: string; + version: number; +} + +interface SyncSessionList { sessions: SyncSessionEntry[]; } +interface SessionBundleWire { + session_id: string; + metadata: Object; + turns: Object[]; + source_device_id?: string; + source_device_name?: string; +} +interface SyncSessionUpload { + session_id: string; + encrypted_data: string; + nonce: string; + version: number; +} + +/** Default BitFun cloud relay used by the desktop account flow. */ +export const DEFAULT_CLOUD_RELAY_URL: string = 'https://remote.openbitfun.com/relay'; + +/** Client for the current desktop relay account protocol. */ +export class CloudAccountClient { + private readonly cipher: HarmonyRemoteCryptoCipher = new HarmonyRemoteCryptoCipher(); + + async login(relayUrl: string, username: string, password: string, deviceId: string): Promise { + const normalizedRelayUrl = relayUrl.trim() || DEFAULT_CLOUD_RELAY_URL; + const normalizedUser = username.trim(); + const startedAt = Date.now(); + RemoteLogger.info(`cloud login start relay=${normalizedRelayUrl}`); + if (normalizedUser.length === 0 || normalizedUser.length > 128 || password.length === 0 || password.length > 1024) { + throw new Error('Invalid account credentials.'); + } + const challengeRequest: LoginChallengeRequest = { username: normalizedUser }; + const challengeStartedAt = Date.now(); + const challenge = await this.post(normalizedRelayUrl, '/api/auth/login/challenge', challengeRequest); + RemoteLogger.info(`cloud login challenge received elapsed_ms=${Date.now() - challengeStartedAt}`); + const params = JSON.parse(challenge.argon2_params) as CloudAccountKdfParams; + const salt = Encoding.base64ToBytes(challenge.salt); + const kdfSalt = Encoding.base64ToBytes(challenge.kdf_salt); + const kek = await CloudAccountCrypto.derivePasswordHash(password, salt, params); + RemoteLogger.info(`cloud login password proof derived elapsed_ms=${Date.now() - startedAt}`); + const masterKey = await this.unwrapMasterKey(kek, challenge.wrapped_master_key); + RemoteLogger.info(`cloud login master key unwrapped elapsed_ms=${Date.now() - startedAt}`); + const passwordHash = await CloudAccountCrypto.derivePasswordHash(password, kdfSalt, params); + const loginRequest: LoginRequest = { + username: normalizedUser, + password_hash: Encoding.bytesToBase64(passwordHash), + device_id: deviceId, + device_name: 'HarmonyOS Phone' + }; + const auth = await this.post(normalizedRelayUrl, '/api/auth/login', loginRequest); + RemoteLogger.info(`cloud login authenticated elapsed_ms=${Date.now() - startedAt}`); + return { token: auth.token, userId: auth.user_id, masterKey }; + } + + async fetchSessions(relayUrl: string, session: CloudAccountSession, since: number = 0): Promise { + const suffix = `/api/sync/sessions?since=${Math.max(0, since)}`; + const payload = await this.request(relayUrl, suffix, 'GET', undefined, session.token); + const bundles: CloudSessionBundle[] = []; + for (const entry of payload.sessions || []) { + const plain = await this.decryptSyncPayload(session.masterKey, entry.encrypted_data, entry.nonce); + const bundle = JSON.parse(plain) as SessionBundleWire; + bundles.push({ + sessionId: bundle.session_id || entry.session_id, + metadata: bundle.metadata, + turns: bundle.turns, + sourceDeviceId: bundle.source_device_id, + sourceDeviceName: bundle.source_device_name, + version: entry.version + }); + } + return bundles; + } + + async listDevices(relayUrl: string, session: CloudAccountSession): Promise { + const devices = await this.request(relayUrl, '/api/devices', 'GET', undefined, session.token); + return devices.map((device: CloudAccountDeviceWire): CloudAccountDevice => ({ + deviceId: device.device_id, + deviceName: device.device_name || device.device_id, + online: device.online, + lastSeenAt: device.last_seen_at + })); + } + + async deviceRpc( + relayUrl: string, + session: CloudAccountSession, + targetDeviceId: string, + command: RemoteCommand + ): Promise { + const target = targetDeviceId.trim(); + if (target.length === 0) { + throw new Error('Remote target device is required.'); + } + const nonce = Encoding.randomBytes(12); + const encrypted = await this.cipher.encrypt( + Encoding.utf8ToBytes(JSON.stringify(command)), + session.masterKey, + nonce + ); + const body: EncryptedPayload = { + encrypted_data: Encoding.bytesToBase64(encrypted), + nonce: Encoding.bytesToBase64(nonce) + }; + const response = await this.request( + relayUrl, + `/api/devices/${encodeURIComponent(target)}/rpc`, + 'POST', + body, + session.token, + 130000 + ); + const plain = await this.cipher.decrypt( + Encoding.base64ToBytes(response.encrypted_data), + session.masterKey, + Encoding.base64ToBytes(response.nonce) + ); + const parsed = Encoding.parseJsonObject(Encoding.bytesToUtf8(plain)); + if (parsed.resp === 'error') { + throw new Error(parsed.message || 'Remote device command failed.'); + } + return parsed; + } + + async deleteSession(relayUrl: string, session: CloudAccountSession, sessionId: string): Promise { + await this.request(relayUrl, `/api/sync/sessions/${encodeURIComponent(sessionId)}`, 'DELETE', undefined, session.token); + } + + async uploadSession(relayUrl: string, session: CloudAccountSession, bundle: CloudSessionBundle): Promise { + const nonce = Encoding.randomBytes(12); + const wire: SessionBundleWire = { + session_id: bundle.sessionId, + metadata: bundle.metadata, + turns: bundle.turns, + source_device_id: bundle.sourceDeviceId, + source_device_name: bundle.sourceDeviceName + }; + const encrypted = await this.cipher.encrypt( + Encoding.utf8ToBytes(JSON.stringify(wire)), session.masterKey, nonce + ); + const version = Date.now(); + const body: SyncSessionUpload = { + session_id: bundle.sessionId, + encrypted_data: Encoding.bytesToBase64(encrypted), + nonce: Encoding.bytesToBase64(nonce), + version + }; + await this.request(relayUrl, '/api/sync/sessions', 'POST', body, session.token); + return version; + } + + private async decryptSyncPayload(masterKey: Uint8Array, data: string, nonceText: string): Promise { + const plain = await this.cipher.decrypt(Encoding.base64ToBytes(data), masterKey, Encoding.base64ToBytes(nonceText)); + return Encoding.bytesToUtf8(plain); + } + + private async unwrapMasterKey(kek: Uint8Array, packed: string): Promise { + const parts = packed.split('.'); + if (parts.length !== 2) { + throw new Error('Invalid wrapped master key.'); + } + const ciphertext = Encoding.base64ToBytes(parts[0]); + const nonce = Encoding.base64ToBytes(parts[1]); + if (kek.length !== 32 || nonce.length !== 12) { + throw new Error('Invalid account encryption parameters.'); + } + try { + const plain = await this.cipher.decrypt(ciphertext, kek, nonce); + if (plain.length !== 32) { + throw new Error('Invalid master key length.'); + } + return plain; + } catch (_err) { + throw new Error('Invalid username or password.'); + } + } + + private async post(relayUrl: string, path: string, body: Object): Promise { + return this.request(relayUrl, path, 'POST', body); + } + + private async request( + relayUrl: string, + path: string, + method: string, + body?: Object, + token: string = '', + readTimeoutMs: number = 120000 + ): Promise { + const request = http.createHttp(); + const base = relayUrl.replace(/\/$/, ''); + try { + const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; + if (token.length > 0) headers.Authorization = `Bearer ${token}`; + const requestOptions: http.HttpRequestOptions = { + method: method === 'GET' ? http.RequestMethod.GET : method === 'DELETE' ? http.RequestMethod.DELETE : http.RequestMethod.POST, + header: headers, + expectDataType: http.HttpDataType.STRING, + connectTimeout: 15000, + readTimeout: readTimeoutMs + }; + if (body !== undefined) requestOptions.extraData = JSON.stringify(body); + const response = await request.request(`${base}${path}`, requestOptions); + const text = typeof response.result === 'string' ? response.result : JSON.stringify(response.result); + if (response.responseCode < 200 || response.responseCode >= 300) { + let message = `Relay login failed (HTTP ${response.responseCode}).`; + try { + const error = JSON.parse(text) as RelayErrorResponse; + if (error.error) message = error.error; + } catch (_err) { + // Keep the status-based error when the relay did not return JSON. + } + throw new CloudAccountRequestError(message, response.responseCode); + } + return JSON.parse(text) as T; + } finally { + request.destroy(); + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets new file mode 100644 index 0000000000..4d7182e458 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets @@ -0,0 +1,27 @@ +import { Encoding } from './Encoding'; +import bitfunCrypto from 'libbitfun_crypto.so'; + +export interface CloudAccountKdfParams { + m: number; + t: number; + p: number; +} + +/** + * Desktop-compatible Argon2id derivation. Keep this adapter isolated so the + * relay protocol and the UI never depend on a particular crypto provider. + */ +export class CloudAccountCrypto { + static async derivePasswordHash(password: string, salt: Uint8Array, params: CloudAccountKdfParams): Promise { + CloudAccountCrypto.validateParams(params, salt); + return bitfunCrypto.argon2idRaw(Encoding.utf8ToBytes(password), salt, params.m, params.t, params.p) as Uint8Array; + } + + private static validateParams(params: CloudAccountKdfParams, salt: Uint8Array): void { + if (salt.length < 8 || salt.length > 64 || + params.m < 8 * 1024 || params.m > 256 * 1024 || + params.t < 1 || params.t > 10 || params.p < 1 || params.p > 16) { + throw new Error('Invalid Argon2id parameters.'); + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets new file mode 100644 index 0000000000..5403f3cf5e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets @@ -0,0 +1,119 @@ +import { huks } from '@kit.UniversalKeystoreKit'; +import { preferences } from '@kit.ArkData'; +import { Encoding } from './Encoding'; + +const STORE_NAME: string = 'bitfun_cloud_account'; +const CIPHER_KEY: string = 'session_cipher'; +const IV_KEY: string = 'session_iv'; +const HUKS_ALIAS: string = 'bitfun_cloud_account_session'; + +export interface PersistedCloudAccountSession { + relayUrl: string; + username: string; + token: string; + userId: string; + masterKey: string; + targetDeviceId?: string; + targetDeviceName?: string; +} + +/** Persists only an HUKS-encrypted account session, never plaintext secrets. */ +export class CloudAccountSessionStore { + private store?: preferences.Preferences; + + async init(context: Context): Promise { + this.store = await preferences.getPreferences(context, STORE_NAME); + } + + async save(session: PersistedCloudAccountSession): Promise { + const store = this.requireStore(); + await this.ensureKey(); + const iv = Encoding.randomBytes(16); + const options = this.options(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT, iv); + const handle = await huks.initSession(HUKS_ALIAS, options); + const encrypted = await huks.finishSession(handle.handle, { + properties: options.properties, + inData: Encoding.utf8ToBytes(JSON.stringify(session)) + }); + if (!encrypted.outData || encrypted.outData.length === 0) { + throw new Error('Cloud account session encryption failed.'); + } + await store.put(CIPHER_KEY, Encoding.bytesToBase64(encrypted.outData)); + await store.put(IV_KEY, Encoding.bytesToBase64(iv)); + await store.flush(); + } + + async load(): Promise { + const store = this.requireStore(); + const cipher = await store.get(CIPHER_KEY, ''); + const ivText = await store.get(IV_KEY, ''); + if (typeof cipher !== 'string' || typeof ivText !== 'string' || cipher.length === 0 || ivText.length === 0) { + return undefined; + } + const iv = Encoding.base64ToBytes(ivText); + const options = this.options(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT, iv); + const handle = await huks.initSession(HUKS_ALIAS, options); + const decrypted = await huks.finishSession(handle.handle, { + properties: options.properties, + inData: Encoding.base64ToBytes(cipher) + }); + if (!decrypted.outData) { + return undefined; + } + return JSON.parse(Encoding.bytesToUtf8(decrypted.outData)) as PersistedCloudAccountSession; + } + + async clear(): Promise { + const store = this.requireStore(); + await store.delete(CIPHER_KEY); + await store.delete(IV_KEY); + await store.flush(); + } + + private async ensureKey(): Promise { + if (await this.keyItemExists()) { + return; + } + await huks.generateKeyItem(HUKS_ALIAS, { + properties: [ + { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES }, + { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 }, + { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT | huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT }, + { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_CBC }, + { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_PKCS7 } + ] + }); + } + + private async keyItemExists(): Promise { + try { + return await huks.isKeyItemExist(HUKS_ALIAS, {}); + } catch (err) { + const message = err instanceof Error ? err.message : JSON.stringify(err); + if (message.indexOf('12000011') >= 0 || message.indexOf('-13') >= 0 || + message.toLowerCase().indexOf('does not exist') >= 0) { + return false; + } + throw new Error(message || 'HUKS key lookup failed.'); + } + } + + private options(purpose: huks.HuksKeyPurpose, iv: Uint8Array): huks.HuksOptions { + return { + properties: [ + { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES }, + { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: purpose }, + { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_CBC }, + { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_PKCS7 }, + { tag: huks.HuksTag.HUKS_TAG_IV, value: iv } + ] + }; + } + + private requireStore(): preferences.Preferences { + if (!this.store) { + throw new Error('Cloud account session store is not initialized.'); + } + return this.store; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectionErrorPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectionErrorPolicy.ets index 18d4693d39..9c1254d1a9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectionErrorPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectionErrorPolicy.ets @@ -114,6 +114,12 @@ export class ConnectionErrorPolicy { if (text.indexOf('permission') >= 0 || text.indexOf('denied') >= 0) { return RemoteI18n.t('errors.permissionDenied'); } + if (text.indexOf('http 401') >= 0 || text.indexOf('unauthorized') >= 0) { + return RemoteI18n.t('remote.settings.accountExpired'); + } + if (text.indexOf('http 404') >= 0 || text.indexOf('http 503') >= 0 || text.indexOf('http 504') >= 0) { + return RemoteI18n.t('remote.settings.deviceUnavailable'); + } return raw || RemoteI18n.t('errors.operationFailed'); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConversationEvent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConversationEvent.ets new file mode 100644 index 0000000000..348ef44a8a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConversationEvent.ets @@ -0,0 +1,71 @@ +import { + ChatMessage, + RemoteToolStatusResponse, + SessionSummary +} from '../model/RemoteModels'; + +/** Transport-neutral events consumed by the shared conversation store. */ +export interface ConversationUserMessageEvent { + type: 'user_message'; + message: ChatMessage; + persisted: boolean; +} + +export interface ConversationAssistantDeltaEvent { + type: 'assistant_delta'; + sessionId: string; + turnId: string; + delta: string; +} + +export interface ConversationAssistantMessageEvent { + type: 'assistant_message'; + message: ChatMessage; +} + +export interface ConversationActiveTurnUpdatedEvent { + type: 'active_turn_updated'; + sessionId: string; + message: ChatMessage; +} + +export interface ConversationToolEvent { + type: 'tool_started' | 'tool_finished'; + sessionId: string; + turnId: string; + tool: RemoteToolStatusResponse; +} + +export interface ConversationTurnStartedEvent { + type: 'turn_started'; + sessionId: string; + turnId: string; +} + +export interface ConversationTurnFinishedEvent { + type: 'turn_finished'; + sessionId: string; + turnId: string; + message?: ChatMessage; +} + +export interface ConversationSessionUpdatedEvent { + type: 'session_updated'; + session: SessionSummary; +} + +export interface ConversationErrorEvent { + type: 'error'; + message: string; +} + +export type ConversationEvent = + | ConversationUserMessageEvent + | ConversationAssistantDeltaEvent + | ConversationAssistantMessageEvent + | ConversationActiveTurnUpdatedEvent + | ConversationToolEvent + | ConversationTurnStartedEvent + | ConversationTurnFinishedEvent + | ConversationSessionUpdatedEvent + | ConversationErrorEvent; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets index 9afea36f4f..ee48ae293d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets @@ -15,6 +15,7 @@ export class RelayHttpClient { private readonly deviceName: string = 'HarmonyOS Phone'; delegatedToken: string = ''; delegatedMasterKey: string = ''; + delegatedUserId: string = ''; pairedDeviceId: string = ''; homeDeviceId: string = ''; @@ -34,6 +35,8 @@ export class RelayHttpClient { async pair(deviceId: string, identity: PairIdentity): Promise { const descriptor = this.requireDescriptor(); const crypto = this.requireCrypto(); + const startedAt = Date.now(); + RemoteLogger.info(`pair start room=${RelayHttpClient.shortRoomId(descriptor.roomId)}`); crypto.deriveSharedKey(descriptor.publicKey); const userId = identity.userId.trim(); @@ -43,6 +46,7 @@ export class RelayHttpClient { device_name: this.deviceName }; const pairResponse = await this.postJson(`/api/rooms/${descriptor.roomId}/pair`, pairRequest); + RemoteLogger.info(`pair challenge received ms=${Date.now() - startedAt}`); const challenge = await crypto.decryptJson(pairResponse); const challengeCommand: ChallengeCommand = { challenge_echo: challenge.challenge, @@ -60,6 +64,7 @@ export class RelayHttpClient { if (initialSync.resp === 'error') { throw new Error(initialSync.message || 'Desktop rejected the pairing request.'); } + RemoteLogger.info(`pair complete ms=${Date.now() - startedAt}`); return initialSync; } @@ -75,6 +80,7 @@ export class RelayHttpClient { if (response.resp === 'delegate_identity' && response.token && response.master_key) { this.delegatedToken = response.token; this.delegatedMasterKey = response.master_key; + this.delegatedUserId = response.user_id || ''; if (response.device_id) { this.homeDeviceId = response.device_id; if (!this.pairedDeviceId) { @@ -92,6 +98,7 @@ export class RelayHttpClient { clearDelegatedIdentity(): void { this.delegatedToken = ''; this.delegatedMasterKey = ''; + this.delegatedUserId = ''; } hasDelegatedIdentity(): boolean { @@ -229,4 +236,8 @@ export class RelayHttpClient { } return requestId.length <= 12 ? requestId : requestId.slice(requestId.length - 12); } + + private static shortRoomId(roomId: string): string { + return roomId.length <= 8 ? roomId : roomId.slice(0, 8); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 3fe776f792..283e388897 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -1,4 +1,4 @@ -import { CreateSessionOptions, RemoteCommand, RemoteImageContext, RemoteQuestionAnswerPayload } from '../model/RemoteModels'; +import { CreateSessionOptions, RemoteCommand, RemoteImageContext, RemotePermissionMode, RemoteQuestionAnswerPayload } from '../model/RemoteModels'; export class RemoteCommandFactory { static getWorkspaceInfo(): RemoteCommand { @@ -154,6 +154,14 @@ export class RemoteCommandFactory { }; } + static getPermissionMode(): RemoteCommand { + return { cmd: 'get_permission_mode' }; + } + + static setPermissionMode(mode: RemotePermissionMode): RemoteCommand { + return { cmd: 'set_permission_mode', mode }; + } + static answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): RemoteCommand { return { cmd: 'answer_question', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets new file mode 100644 index 0000000000..b492b6f853 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets @@ -0,0 +1,51 @@ +import { CloudAccountClient, CloudAccountSession } from './CloudAccountClient'; +import { CommandStatusResponse, RemoteCommand } from '../model/RemoteModels'; +import { RelayHttpClient } from './RelayHttpClient'; + +export interface RemoteCommandTransport { + send(command: RemoteCommand, timeoutMs?: number): Promise; + reset(): void; +} + +export class RoomRemoteCommandTransport implements RemoteCommandTransport { + private readonly client: RelayHttpClient; + + constructor(client: RelayHttpClient) { + this.client = client; + } + + async send(command: RemoteCommand, timeoutMs: number = 30000): Promise { + return this.client.sendCommand(command, timeoutMs); + } + + reset(): void { + this.client.reset(); + } +} + +export class AccountDeviceCommandTransport implements RemoteCommandTransport { + private readonly client: CloudAccountClient; + private readonly relayUrl: string; + private readonly session: CloudAccountSession; + private readonly targetDeviceId: string; + + constructor( + client: CloudAccountClient, + relayUrl: string, + session: CloudAccountSession, + targetDeviceId: string + ) { + this.client = client; + this.relayUrl = relayUrl; + this.session = session; + this.targetDeviceId = targetDeviceId; + } + + async send(command: RemoteCommand, _timeoutMs: number = 120000): Promise { + return this.client.deviceRpc(this.relayUrl, this.session, this.targetDeviceId, command); + } + + reset(): void { + // Account credentials are owned by CloudAccountSessionStore, not by this transport. + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets new file mode 100644 index 0000000000..001dc5e446 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets @@ -0,0 +1,79 @@ +import { InitialSyncResult } from '../model/RemoteModels'; +import { MobileIdentityStore } from './MobileIdentityStore'; +import { RemoteDescriptorParser } from './RemoteDescriptorParser'; +import { RemotePairingPolicy } from './RemotePairingPolicy'; +import { RemoteSessionManager } from './RemoteSessionManager'; +import { AsyncLifecycleGate } from './AsyncLifecycleGate'; + +export interface RemoteConnectionRequest { + remoteUrl: string; + userId: string; + deviceId: string; + accountPassword: string; + autoReconnect: boolean; +} + +/** Owns remote protocol setup; UI state is returned to the page as a result. */ +export class RemoteConnectionCoordinator { + private readonly sessionManager: RemoteSessionManager; + private readonly identityStore: MobileIdentityStore; + private readonly pairingPolicy: RemotePairingPolicy; + private readonly lifecycleGate: AsyncLifecycleGate; + private activeToken: number = 0; + + constructor( + sessionManager: RemoteSessionManager, + identityStore: MobileIdentityStore, + pairingPolicy: RemotePairingPolicy, + lifecycleGate: AsyncLifecycleGate + ) { + this.sessionManager = sessionManager; + this.identityStore = identityStore; + this.pairingPolicy = pairingPolicy; + this.lifecycleGate = lifecycleGate; + } + + async connect(request: RemoteConnectionRequest): Promise { + const token: number = this.lifecycleGate.begin(); + this.activeToken = token; + const descriptor = RemoteDescriptorParser.parse(request.remoteUrl); + const identity = this.pairingPolicy.identityForConnect( + descriptor, + request.userId, + request.deviceId, + request.accountPassword, + request.autoReconnect + ); + const initialSync: InitialSyncResult = await this.sessionManager.connect( + descriptor, + identity, + request.deviceId + ); + if (!this.lifecycleGate.isCurrent(token)) { + throw new Error('Remote connection was invalidated'); + } + await this.identityStore.savePairingInput(identity.userId, request.remoteUrl); + await this.identityStore.clearUserIdProtection(); + if (!this.lifecycleGate.isCurrent(token)) { + throw new Error('Remote connection was invalidated'); + } + return initialSync; + } + + invalidate(): void { + this.lifecycleGate.invalidate(); + } + + isCurrentRequest(): boolean { + return this.activeToken > 0 && this.lifecycleGate.isCurrent(this.activeToken); + } + + async ping(): Promise { + await this.sessionManager.ping(); + } + + reset(): void { + this.invalidate(); + this.sessionManager.reset(); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCrypto.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCrypto.ets index 131ac45b96..538653ebb4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCrypto.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCrypto.ets @@ -14,7 +14,7 @@ export interface RemoteCryptoOptions { randomBytes?: (size: number) => Uint8Array; } -class HarmonyRemoteCryptoCipher implements RemoteCryptoCipher { +export class HarmonyRemoteCryptoCipher implements RemoteCryptoCipher { async encrypt(plainBytes: Uint8Array, key: Uint8Array, nonce: Uint8Array): Promise { const symKey = await cryptoFramework.createSymKeyGenerator('AES256').convertKey({ data: key }); const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets index 85ab8e85ef..05e40c7665 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets @@ -1,6 +1,9 @@ +import fs from '@ohos.file.fs'; +import picker from '@ohos.file.picker'; import { FileInfo, ReadFileResult } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { Encoding } from './Encoding'; import { RemoteUiState } from './RemoteUiState'; export interface RemoteFileDownloadClient { @@ -23,6 +26,26 @@ class SystemRemoteFileDownloadScheduler implements RemoteFileDownloadScheduler { } } +class SystemRemoteFileSaver { + async save(result: ReadFileResult): Promise { + const options = new picker.DocumentSaveOptions(); + options.newFileNames = [result.name]; + options.autoCreateEmptyFile = true; + const uris = await new picker.DocumentViewPicker().save(options); + const uri = uris.length > 0 ? uris[0] : ''; + if (uri.length === 0) { + throw new Error('No destination file was selected.'); + } + const file = fs.openSync(uri, fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC); + try { + fs.writeSync(file.fd, Encoding.base64ToBytes(result.contentBase64).buffer); + } finally { + fs.closeSync(file); + } + return uri; + } +} + export class RemoteFileDownloadController { private readonly client: RemoteFileDownloadClient; private readonly onDownloadStatus: (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => void; @@ -72,6 +95,7 @@ export class RemoteFileDownloadController { `${RemoteUiState.formatBytes(downloaded)} / ${RemoteUiState.formatBytes(total)}` ); }); + await new SystemRemoteFileSaver().save(result); this.setDownloadStatus( path, path, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index b9337546d8..a1fd0f7c64 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets @@ -129,7 +129,8 @@ export class RemoteSessionController { agentType: string, isBusy: boolean, remoteAvailable: boolean, - onCreated: (session: SessionSummary) => Promise + onCreated: (session: SessionSummary) => Promise, + instruction: string = '' ): Promise { if (isBusy || !remoteAvailable) { return; @@ -140,7 +141,7 @@ export class RemoteSessionController { const session = await this.client.createSession({ agentType, title: '', - instruction: '' + instruction }); this.callbacks.onActiveSession(session); this.callbacks.onStatusText(RemoteI18n.t('status.sessionCreated')); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 543aa04561..aa12b94d6b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,6 +1,8 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, RelayHttpClient } from './RelayHttpClient'; +import { CloudAccountClient, CloudAccountSession } from './CloudAccountClient'; +import { AccountDeviceCommandTransport, RemoteCommandTransport, RoomRemoteCommandTransport } from './RemoteCommandTransport'; import { RemoteCommandFactory } from './RemoteCommandFactory'; import { RemoteCrypto } from './RemoteCrypto'; import { RemoteChatCommandClient } from './RemoteChatCommandController'; @@ -11,22 +13,40 @@ import { RemoteResponseMapper } from './RemoteResponseMapper'; import { RemoteSessionClient } from './RemoteSessionController'; import { RemoteToolActionClient } from './RemoteToolActionController'; +export interface DelegatedAccountSession { + relayUrl: string; + session: CloudAccountSession; +} + export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { - private readonly client: RelayHttpClient = new RelayHttpClient(); + private readonly roomClient: RelayHttpClient = new RelayHttpClient(); + private transport?: RemoteCommandTransport; + private transportGeneration: number = 0; private crypto?: RemoteCrypto; private workspace?: WorkspaceInfo; + private roomRelayUrl: string = ''; reset(): void { + this.transportGeneration += 1; this.crypto = undefined; this.workspace = undefined; - this.client.reset(); + this.roomRelayUrl = ''; + this.transport?.reset(); + this.transport = undefined; + this.roomClient.reset(); } async connect(descriptor: RemoteDescriptor, identity: PairIdentity, deviceId: string): Promise { + this.transportGeneration += 1; this.crypto = new RemoteCrypto(); - this.client.bind(descriptor, this.crypto); - const initialSync = await this.client.pair(deviceId, identity); - await this.client.requestDelegatedIdentity(); + this.roomRelayUrl = descriptor.relayUrl; + this.roomClient.bind(descriptor, this.crypto); + this.transport?.reset(); + this.transport = new RoomRemoteCommandTransport(this.roomClient); + const initialSync = await this.roomClient.pair(deviceId, identity); + // Account inheritance is optional for room pairing. A desktop without an + // account, or an older desktop build, must not make normal QR control fail. + await this.roomClient.requestDelegatedIdentity(); this.workspace = RemoteResponseMapper.workspaceFromInitialSync(initialSync); return { workspace: this.workspace, @@ -36,6 +56,41 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile }; } + delegatedAccountSession(): DelegatedAccountSession | undefined { + if (!this.roomClient.hasDelegatedIdentity() || this.roomClient.delegatedUserId.trim().length === 0 || + this.roomRelayUrl.trim().length === 0) { + return undefined; + } + return { + relayUrl: this.roomRelayUrl, + session: { + token: this.roomClient.delegatedToken, + userId: this.roomClient.delegatedUserId, + masterKey: Encoding.base64ToBytes(this.roomClient.delegatedMasterKey) + } + }; + } + + async connectAccountDevice( + accountClient: CloudAccountClient, + relayUrl: string, + session: CloudAccountSession, + targetDeviceId: string + ): Promise { + this.transportGeneration += 1; + this.transport?.reset(); + this.transport = new AccountDeviceCommandTransport(accountClient, relayUrl, session, targetDeviceId); + const workspaceResponse = await this.send(RemoteCommandFactory.getWorkspaceInfo()); + this.workspace = RemoteResponseMapper.workspaceFromResponse(workspaceResponse); + const sessions = await this.listSessions(50, 0, '', ''); + return { + workspace: this.workspace, + sessions: sessions.sessions, + hasMoreSessions: sessions.hasMore, + authenticatedUserId: session.userId + }; + } + async getWorkspaceInfo(): Promise { const response = await this.send(RemoteCommandFactory.getWorkspaceInfo()); return RemoteResponseMapper.workspaceFromResponse(response); @@ -127,7 +182,10 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile const command = RemoteCommandFactory.createSession(options, workspacePath); const response = await this.send(command); const sessionId = response.session_id || response.id || ''; - const title = response.title || `${options.agentType === 'cowork' ? 'Cowork' : 'Code'} Session`; + const normalizedAgentType = options.agentType.toLowerCase(); + const fallbackTitle = normalizedAgentType === 'claw' || normalizedAgentType === 'assistant' || normalizedAgentType === 'chat' ? + 'Assistant Session' : normalizedAgentType === 'cowork' ? 'Cowork Session' : 'Code Session'; + const title = response.title || fallbackTitle; let initialTurnId = ''; if (options.instruction.trim().length > 0) { initialTurnId = await this.sendMessage(sessionId, options.instruction.trim(), options.agentType); @@ -236,6 +294,16 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile await this.send(RemoteCommandFactory.cancelTool(toolId, reason)); } + async getPermissionMode(): Promise { + const response = await this.send(RemoteCommandFactory.getPermissionMode()); + return response.mode || 'ask'; + } + + async setPermissionMode(mode: RemotePermissionMode): Promise { + const response = await this.send(RemoteCommandFactory.setPermissionMode(mode)); + return response.mode || mode; + } + async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { await this.send(RemoteCommandFactory.answerQuestion(toolId, answers)); } @@ -288,7 +356,16 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile private async send(command: RemoteCommand, readTimeoutMs: number = 30000): Promise { command._request_id = `req_${Date.now()}_${Encoding.randomId('harmony').slice(0, 20)}`; - return this.client.sendCommand(command, readTimeoutMs); + if (!this.transport) { + throw new Error('Remote transport is not connected.'); + } + const generation = this.transportGeneration; + const transport = this.transport; + const response = await transport.send(command, readTimeoutMs); + if (generation !== this.transportGeneration || transport !== this.transport) { + throw new Error('Remote target changed while the request was in flight.'); + } + return response; } private static normalizedSessionFilter(agentType: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets index dc9f84355b..680afadb5c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets @@ -104,7 +104,7 @@ export class RemoteToolActionController { successText: string, action: () => Promise ): Promise { - if (sessionId.length === 0 || toolId.length === 0 || isBusy || !remoteAvailable) { + if (sessionId.length === 0 || toolId.length === 0 || !remoteAvailable) { return; } try { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets new file mode 100644 index 0000000000..b0cf3d2c5c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets @@ -0,0 +1,45 @@ +import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; +import { RemoteWorkspaceDataSource } from './RemoteWorkspaceRepository'; +import { RemoteLogger } from './RemoteLogger'; + +/** Coordinates workspace/assistant selection and cross-workspace session discovery. */ +export class RemoteWorkspaceCoordinator { + private readonly repository: RemoteWorkspaceDataSource; + + constructor(repository: RemoteWorkspaceDataSource) { + this.repository = repository; + } + + async recentWorkspaces(): Promise { + return await this.repository.listRecentWorkspaces(); + } + + async assistants(): Promise { + return await this.repository.listAssistants(); + } + + async switchWorkspace(path: string): Promise { + return await this.repository.setWorkspace(path); + } + + async switchAssistant(path: string): Promise { + return await this.repository.setAssistant(path); + } + + async sessionsForWorkspaces(paths: string[]): Promise { + const all: RemoteSession[] = []; + for (const path of paths) { + try { + const sessions = await this.repository.listSessionsForWorkspace(path, 50); + sessions.forEach((item: RemoteSession) => { + if (!all.some((existing: RemoteSession) => existing.id === item.id)) { + all.push(item); + } + }); + } catch (err) { + RemoteLogger.warn(`load workspace sessions failed path=${path}: ${String(err)}`); + } + } + return all; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets new file mode 100644 index 0000000000..cfd6c9d43d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets @@ -0,0 +1,51 @@ +import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; +import { RemoteSessionManager } from './RemoteSessionManager'; + +export interface RemoteWorkspaceDataSource { + listRecentWorkspaces(): Promise; + setWorkspace(path: string): Promise; + listAssistants(): Promise; + setAssistant(path: string): Promise; + listSessionsForWorkspace(path: string, limit: number): Promise; + ping(): Promise; + reset(): void; +} + +/** Repository boundary for workspace, assistant and connection health data. */ +export class RemoteWorkspaceRepository implements RemoteWorkspaceDataSource { + private readonly sessionManager: RemoteSessionManager; + + constructor(sessionManager: RemoteSessionManager) { + this.sessionManager = sessionManager; + } + + async listRecentWorkspaces(): Promise { + return await this.sessionManager.listRecentWorkspaces(); + } + + async setWorkspace(path: string): Promise { + await this.sessionManager.setWorkspace(path); + return await this.sessionManager.getWorkspaceInfo(); + } + + async listAssistants(): Promise { + return await this.sessionManager.listAssistants(); + } + + async setAssistant(path: string): Promise { + await this.sessionManager.setAssistant(path); + return await this.sessionManager.getWorkspaceInfo(); + } + + async listSessionsForWorkspace(path: string, limit: number): Promise { + return await this.sessionManager.listSessionsForWorkspace(path, limit); + } + + async ping(): Promise { + await this.sessionManager.ping(); + } + + reset(): void { + this.sessionManager.reset(); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_chat.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_chat.png new file mode 100644 index 0000000000..7ad422a904 Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_chat.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_device.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_device.png new file mode 100644 index 0000000000..7679f12edf Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_device.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_folder.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_folder.png new file mode 100644 index 0000000000..14156a9c4c Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_folder.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_switch.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_switch.png new file mode 100644 index 0000000000..441475b52a Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_create_switch.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets new file mode 100644 index 0000000000..5f64efebf6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets @@ -0,0 +1,33 @@ +import { Want } from '@kit.AbilityKit'; +import { Driver, ON, abilityDelegatorRegistry } from '@kit.TestKit'; +import { describe, it, expect } from '@ohos/hypium'; + +const APP_BUNDLE: string = 'com.example.bitfun_mobile'; +const APP_ABILITY: string = 'EntryAbility'; + +export default function deviceSmokeTest() { + describe('BitFunDeviceSmoke', () => { + it('opens the home surface and remote conversation entry', 0, async () => { + const want: Want = { + bundleName: APP_BUNDLE, + abilityName: APP_ABILITY + }; + await abilityDelegatorRegistry.getAbilityDelegator().startAbility(want); + const driver = Driver.create(); + await driver.delayMs(800); + + const homeTitle = await driver.findComponent(ON.text('BitFun')); + expect(homeTitle !== undefined).assertTrue(); + + await driver.click(130, 180); + await driver.delayMs(300); + const remoteEntry = await driver.findComponent(ON.text('Remote')); + expect(remoteEntry !== undefined).assertTrue(); + await remoteEntry.click(); + await driver.delayMs(900); + + const remoteTitle = await driver.findComponent(ON.text('远程')); + expect(remoteTitle !== undefined).assertTrue(); + }); + }); +} diff --git a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/List.test.ets b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/List.test.ets index 794c7dc4ed..3c76d3fb42 100644 --- a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/List.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/List.test.ets @@ -1,5 +1,7 @@ import abilityTest from './Ability.test'; +import deviceSmokeTest from './DeviceSmoke.test'; export default function testsuite() { abilityTest(); -} \ No newline at end of file + deviceSmokeTest(); +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets new file mode 100644 index 0000000000..6be1f8f3ad --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -0,0 +1,68 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; +import { ConversationEvent } from '../main/ets/services/ConversationEvent'; +import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; +import { ChatMessage } from '../main/ets/model/RemoteModels'; +import { AppShellViewModel } from '../main/ets/pages/state/AppShellViewModel'; +import { AppNavigationBackAction } from '../main/ets/pages/navigation/AppRouteContract'; + +export default function architectureUnitTest() { + describe('MobileArchitecture', () => { + it('invalidates asynchronous work by generation', 0, () => { + const gate = new AsyncLifecycleGate(); + const first = gate.begin(); + expect(gate.isCurrent(first)).assertTrue(); + gate.invalidate(); + expect(gate.isCurrent(first)).assertFalse(); + }); + + it('reduces active turn events through the shared timeline', 0, () => { + const store = new ChatTimelineStore(); + const message: ChatMessage = { + id: 'active-turn', + turnId: 'turn-1', + role: 'assistant', + text: 'streamed', + status: 'streaming', + detail: '', + timestamp: '2026-01-01T00:00:00.000Z', + items: [] + }; + const event: ConversationEvent = { + type: 'active_turn_updated', + sessionId: 'session-1', + message + }; + store.applyEvent(event); + expect(store.snapshot().activeTurn?.text || '').assertEqual('streamed'); + }); + + it('projects local and remote conversations through one view state', 0, () => { + const store = new ChatTimelineStore(); + const remotePage = new RemotePageState(); + const generalPage = new GeneralChatPageState(); + remotePage.setActiveSession({ sessionId: 'remote-1', title: '', workspacePath: '', agentType: 'code' }); + generalPage.setActiveSession({ sessionId: 'general-1', title: '', workspacePath: '', agentType: 'chat' }); + const remote = ConversationViewState.project(AppRoute.RemoteChat, remotePage, generalPage, ''); + const general = ConversationViewState.project(AppRoute.GeneralChat, remotePage, generalPage, ''); + expect(remote.activeSession.sessionId).assertEqual('remote-1'); + expect(general.activeSession.sessionId).assertEqual('general-1'); + }); + + it('owns navigation and global overlay state in AppShellViewModel', 0, () => { + const shell = new AppShellViewModel(); + shell.pushRoute(AppRoute.RemoteHome); + expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); + shell.state.setSidebarVisible(true); + expect(shell.backAction(AppRoute.RemoteHome)).assertEqual(AppNavigationBackAction.CloseSidebar); + shell.state.setSidebarVisible(false); + shell.replaceRoute(AppRoute.GeneralChat, 'session-1'); + expect(shell.currentRoute()).assertEqual(AppRoute.GeneralChat); + expect(shell.isGeneralChatVisible()).assertTrue(); + }); + }); +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets new file mode 100644 index 0000000000..e6c2b1316b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -0,0 +1,1025 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; +import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; +import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; +import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; +import { ConversationEvent } from '../main/ets/services/ConversationEvent'; +import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; +import { Encoding } from '../main/ets/services/Encoding'; +import { + GeneralChatDraftStore, + GeneralChatLocalStore, + GeneralChatSendResult, + GeneralChatStreamCallbacks, + GeneralChatStreamObserver +} from '../main/ets/services/general-chat/GeneralChatPort'; +import { GeneralChatApiClient } from '../main/ets/services/general-chat/GeneralChatApiClient'; +import { GeneralChatBootstrapController } from '../main/ets/services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ets/services/general-chat/GeneralChatCommandController'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigValidator +} from '../main/ets/services/general-chat/GeneralChatConfigStore'; +import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; +import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; +import { + GeneralChatHttpHeaders, + GeneralChatHttpMethod, + GeneralChatHttpResponse, + GeneralChatHttpTransport, + GeneralChatTokenProvider +} from '../main/ets/services/general-chat/GeneralChatHttpTransport'; +import { GeneralChatRepository } from '../main/ets/services/general-chat/GeneralChatRepository'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../main/ets/services/general-chat/GeneralChatServiceState'; +import { GeneralChatStreamCoordinator } from '../main/ets/services/general-chat/GeneralChatStreamCoordinator'; +import { GeneralChatStreamLifecycleController } from '../main/ets/services/general-chat/GeneralChatStreamLifecycleController'; +import { + ModelProviderGeneralChatAdapter, + ModelProviderMessage, + ModelProviderProtocol +} from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; +import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; +import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; +import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; +import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; +import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; +import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; +import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler +} from '../main/ets/services/RemoteFileDownloadController'; +import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; +import { + RemoteModelClient, + RemoteModelController, + RemoteModelPreferenceStore +} from '../main/ets/services/RemoteModelController'; +import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; +import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; +import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; +import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; +import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; +import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; +import { RemoteUiState } from '../main/ets/services/RemoteUiState'; +import { + VoiceInputLifecycleCallbacks, + VoiceInputLifecycleController, + VoiceInputRouteSnapshot +} from '../main/ets/services/VoiceInputLifecycleController'; +import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; +import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES +} from '../main/ets/pages/components/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + AppNavigationBackAction, + AppNavigationPathSpec, + AppRoute, + AppRouteContract +} from '../main/ets/pages/navigation/AppRouteContract'; +import { TimeFormat } from '../main/ets/services/TimeFormat'; +import { X25519 } from '../main/ets/services/X25519'; +import { + ChatMessage, + FileInfo, + ImageAttachment, + PollSessionResult, + RecentWorkspaceEntry, + AssistantEntry, + CreateSessionOptions, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteModelCatalog, + ReadFileResult, + RemoteSession, + SelectedImageAttachment, + SessionListResult, + SessionMessagesResult, + SessionSummary, + WorkspaceInfo +} from '../main/ets/model/RemoteModels'; + +import { + ToolInputFixture, + CryptoImageFixture, + CryptoCommandFixture, + CryptoSimpleCommandFixture, + GeneralChatRecordedRequest, + FakeRemoteWorkspaceDataSource, + FakeRemoteCryptoCipher, + hexToBytes, + bytesToHex, + fixedRandomBytes, + expectCommandJson, + connectedPeers, + chatMessage, + remoteSession, + selectedImage, + remoteModelCatalog, + activeChatMessage, + chatMessageWithImage, + imageAttachment, + pollResult, + delay, + FakePollSessionManager, + BlockingPollSessionManager, + FakeGeneralChatLocalStore, + FailingGeneralChatAdapter, + PartialFailingGeneralChatAdapter, + FakeGeneralChatTokenProvider, + FakeGeneralChatHttpTransport, + FakeRemoteHeartbeatScheduler, + FakeGeneralChatDraftScheduler, + FakeVoiceInputLifecycleService, + FakeRemoteFileDownloadScheduler, + FakeRemoteFileDownloadClient, + RemoteDownloadStatusRecord, + FakeRemoteToolActionClient, + FakeRemoteModelClient, + FakeRemoteModelPreferenceStore, + RemoteModelProjectionRecord, + FakeRemoteSessionClient, + RemoteSessionProjectionRecord, + FakeRemoteChatCommandClient, + RemoteChatMessagesProjectionRecord, + FakeGeneralChatCommandClient, + FakeGeneralChatConfigStore +} from './LocalTestFixtures'; + +export default function conversationStateUnitTest() { + describe('ChatTimelineProjector', () => { + it('filters seed messages and returns empty state for empty real history', 0, () => { + const items = ChatTimelineProjector.project([ + chatMessage('system-seed', 'assistant', 'Welcome') + ], [], chatMessage('', 'assistant', ''), false); + + expect(items.length).assertEqual(1); + expect(items[0].type).assertEqual('empty_state'); + }); + + it('deduplicates optimistic user messages replaced by persisted messages', 0, () => { + const items = ChatTimelineProjector.project([ + chatMessage('remote-user-1', 'user', 'Explain this file') + ], [ + chatMessage('msg-local-1', 'user', ' Explain this file ') + ], chatMessage('', 'assistant', ''), false); + + expect(items.length).assertEqual(1); + expect(items[0].id).assertEqual('message-remote-user-1'); + expect(items[0].type).assertEqual('user_message'); + }); + + it('keeps optimistic image messages when persisted image payload differs', 0, () => { + const items = ChatTimelineProjector.project([ + chatMessageWithImage('remote-user-1', 'user', 'Analyze image', 'remote.png', 'data:image/png;base64,remote') + ], [ + chatMessageWithImage('msg-local-1', 'user', 'Analyze image', 'local.png', 'data:image/png;base64,local') + ], chatMessage('', 'assistant', ''), false); + + expect(items.length).assertEqual(2); + expect(items[0].id).assertEqual('message-remote-user-1'); + expect(items[1].id).assertEqual('pending-msg-local-1'); + expect(items[1].type).assertEqual('optimistic_user_message'); + }); + + it('keeps active turn until matching assistant message is persisted', 0, () => { + const activeTurn = chatMessage('active-turn-1', 'assistant', 'Done with the edit', 'completed'); + const beforeFinal = ChatTimelineProjector.project([ + chatMessage('remote-user-1', 'user', 'Please edit') + ], [], activeTurn, false); + const afterFinal = ChatTimelineProjector.project([ + chatMessage('remote-user-1', 'user', 'Please edit'), + chatMessage('assistant-final-1', 'assistant', 'Done with the edit') + ], [], activeTurn, false); + + expect(beforeFinal.length).assertEqual(2); + expect(beforeFinal[1].type).assertEqual('assistant_live_turn'); + expect(beforeFinal[1].isFinalizing).assertEqual(true); + expect(afterFinal.length).assertEqual(2); + expect(afterFinal[1].id).assertEqual('message-assistant-final-1'); + expect(afterFinal[1].type).assertEqual('assistant_message'); + }); + + it('updates active turn item id when visible content changes', 0, () => { + const first = activeChatMessage('turn-stable-1', 'abc', 'active', 1); + const second = activeChatMessage('turn-stable-1', 'abcdef', 'active', 2); + const firstItems = ChatTimelineProjector.project([], [], first, false); + const secondItems = ChatTimelineProjector.project([], [], second, false); + + expect(firstItems.length).assertEqual(1); + expect(secondItems.length).assertEqual(1); + expect(firstItems[0].id.indexOf('active-turn-stable-1-')).assertEqual(0); + expect(secondItems[0].id.indexOf('active-turn-stable-1-')).assertEqual(0); + expect(secondItems[0].id === firstItems[0].id).assertFalse(); + }); + + it('hides active turn when final assistant id matches turn id', 0, () => { + const activeTurn = activeChatMessage('turn-final-1', 'partial active text', 'completed'); + const items = ChatTimelineProjector.project([ + chatMessage('turn-final-1_assistant', 'assistant', 'rewritten final text') + ], [], activeTurn, false); + + expect(items.length).assertEqual(1); + expect(items[0].id).assertEqual('message-turn-final-1_assistant'); + expect(items[0].type).assertEqual('assistant_message'); + }); + + it('keeps active turn when matching persisted assistant has no final text yet', 0, () => { + const activeTurn = activeChatMessage('turn-final-wait-1', 'final answer', 'completed'); + const pendingFinal = chatMessage('turn-final-wait-1_assistant', 'assistant', ''); + pendingFinal.text = 'Still reasoning'; + pendingFinal.thinking = 'Still reasoning'; + const items = ChatTimelineProjector.project([pendingFinal], [], activeTurn, false); + + expect(items.length).assertEqual(2); + expect(items[1].type).assertEqual('assistant_live_turn'); + expect(items[1].isFinalizing).assertEqual(true); + }); + }); + + describe('ChatTimelineStore', () => { + it('updates same-id persisted messages when final content arrives later', 0, () => { + const pendingFinal = chatMessage('assistant-final-same-id', 'assistant', ''); + pendingFinal.text = 'Still reasoning'; + pendingFinal.thinking = 'Still reasoning'; + const merged = RemoteUiState.mergeMessages([pendingFinal], [ + chatMessage('assistant-final-same-id', 'assistant', 'Final answer') + ]); + + expect(merged.length).assertEqual(1); + expect(merged[0].text).assertEqual('Final answer'); + expect(merged[0].thinking || '').assertEqual(''); + }); + + it('owns optimistic merge and active turn cleanup rules', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + store.appendOptimisticMessage(chatMessage('msg-local-1', 'user', 'Please edit')); + store.setActiveTurn(chatMessage('active-turn-1', 'assistant', 'Final text', 'completed')); + store.mergePersistedMessages([ + chatMessage('remote-user-1', 'user', 'Please edit') + ]); + + let state = store.snapshot(); + expect(state.persistedMessages.length).assertEqual(1); + expect(state.optimisticMessages.length).assertEqual(0); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-1'); + expect(state.syncPhase).assertEqual('finalizing'); + + store.mergePersistedMessages([ + chatMessage('assistant-final-1', 'assistant', 'Final text') + ]); + state = store.snapshot(); + expect(state.persistedMessages.length).assertEqual(2); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); + }); + + it('projects empty state through the store', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-empty'); + const items = store.project(false); + + expect(items.length).assertEqual(1); + expect(items[0].type).assertEqual('empty_state'); + }); + + it('applies transport-neutral turn events to the shared timeline', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-events'); + const started: ConversationEvent = { + type: 'turn_started', sessionId: 'session-events', turnId: 'turn-1' + }; + const firstDelta: ConversationEvent = { + type: 'assistant_delta', + sessionId: 'session-events', + turnId: 'turn-1', + delta: 'Hello' + }; + const secondDelta: ConversationEvent = { + type: 'assistant_delta', + sessionId: 'session-events', + turnId: 'turn-1', + delta: ' world' + }; + store.applyEvent(started); + store.applyEvent(firstDelta); + store.applyEvent(secondDelta); + + const state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.text : '').assertEqual('Hello world'); + expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-1'); + expect(state.syncPhase).assertEqual('streaming'); + }); + + it('ignores events from a different session', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-current'); + const started: ConversationEvent = { + type: 'turn_started', sessionId: 'session-other', turnId: 'turn-other' + }; + const staleDelta: ConversationEvent = { + type: 'assistant_delta', + sessionId: 'session-other', + turnId: 'turn-other', + delta: 'stale' + }; + store.applyEvent(started); + store.applyEvent(staleDelta); + + const state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); + }); + + it('applies full active turn snapshots through the shared event reducer', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-current'); + const active = activeChatMessage('turn-shared-1', 'streamed answer', 'active', 4); + const update: ConversationEvent = { + type: 'active_turn_updated', + sessionId: 'session-current', + message: active + }; + store.applyEvent(update); + + const state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.text : '').assertEqual('streamed answer'); + expect(state.activeTurn ? (state.activeTurn.renderVersion || 0) : 0).assertEqual(4); + expect(state.syncPhase).assertEqual('streaming'); + }); + + it('creates a local active turn from send response turn ids', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + store.setLocalActiveTurn(' turn-preview-1 '); + + let state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-preview-1'); + expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-preview-1'); + expect(state.activeTurn ? state.activeTurn.status : '').assertEqual('active'); + expect(state.syncPhase).assertEqual('streaming'); + + store.setLocalActiveTurn('turn-preview-2'); + state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-preview-1'); + }); + + it('replaces pending active turns with remote turn ids', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + const pendingId = store.setPendingActiveTurn('msg-local-1'); + + let state = store.snapshot(); + expect(pendingId).assertEqual('active-pending-msg-local-1'); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-pending-msg-local-1'); + expect(state.syncPhase).assertEqual('streaming'); + + store.setLocalActiveTurn('turn-remote-1'); + state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-remote-1'); + expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-remote-1'); + }); + + it('keeps streamed active turn text when send response arrives later', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + const active = chatMessage('active-turn-remote-1', 'assistant', 'partial text', 'active'); + active.turnId = 'turn-remote-1'; + store.setActiveTurn(active); + + store.setLocalActiveTurn('turn-remote-1'); + + const state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-remote-1'); + expect(state.activeTurn ? state.activeTurn.text : '').assertEqual('partial text'); + expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-remote-1'); + }); + + it('keeps active turn text from going backwards for the same turn id', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + store.setActiveTurn(activeChatMessage('turn-stream-1', 'abcdef')); + store.setActiveTurn(activeChatMessage('turn-stream-1', 'abc', 'active', 2)); + + const state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.text : '').assertEqual('abcdef'); + expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-stream-1'); + expect(state.activeTurn ? (state.activeTurn.renderVersion || 0) : 0).assertEqual(2); + }); + + it('monotonically merges structured text items for active turns', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + const first = activeChatMessage('turn-items-1', ''); + first.items = [{ + type: 'text', + content: 'abcdef' + }]; + const second = activeChatMessage('turn-items-1', '', 'active', 2); + second.items = [{ + type: 'text', + content: 'abc' + }]; + + store.setActiveTurn(first); + store.setActiveTurn(second); + + const state = store.snapshot(); + const items = state.activeTurn && state.activeTurn.items ? state.activeTurn.items : []; + expect(items.length).assertEqual(1); + expect(items[0].content || '').assertEqual('abcdef'); + }); + + it('updates active tool status from latest structured item snapshot', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + const first = activeChatMessage('turn-tool-1', ''); + first.items = [{ + type: 'tool', + tool: { + id: 'tool-1', + name: 'read_file', + status: 'running' + } + }]; + const second = activeChatMessage('turn-tool-1', '', 'active', 2); + second.items = [{ + type: 'tool', + tool: { + id: 'tool-1', + name: 'read_file', + status: 'completed', + duration_ms: 50 + } + }]; + + store.setActiveTurn(first); + store.setActiveTurn(second); + + const state = store.snapshot(); + const items = state.activeTurn && state.activeTurn.items ? state.activeTurn.items : []; + expect(items.length).assertEqual(1); + expect(items[0].tool ? (items[0].tool.status || '') : '').assertEqual('completed'); + expect(items[0].tool ? (items[0].tool.duration_ms || 0) : 0).assertEqual(50); + }); + + it('clears active turn when persisted assistant id matches turn id', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + store.setActiveTurn(activeChatMessage('turn-final-store-1', 'partial active text', 'completed')); + store.mergePersistedMessages([ + chatMessage('turn-final-store-1_assistant', 'assistant', 'rewritten final text') + ]); + + const state = store.snapshot(); + expect(state.persistedMessages.length).assertEqual(1); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); + }); + + it('holds completed active turn until persisted assistant has final content', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + store.setActiveTurn(activeChatMessage('turn-final-store-wait-1', 'final answer', 'completed')); + + const pendingFinal = chatMessage('turn-final-store-wait-1_assistant', 'assistant', ''); + pendingFinal.text = 'Still reasoning'; + pendingFinal.thinking = 'Still reasoning'; + store.mergePersistedMessages([pendingFinal]); + store.setActiveTurn(undefined); + + let state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-final-store-wait-1'); + expect(state.syncPhase).assertEqual('finalizing'); + + store.mergePersistedMessages([ + chatMessage('turn-final-store-wait-1_assistant', 'assistant', 'final answer') + ]); + state = store.snapshot(); + expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); + }); + }); + + describe('MarkdownParser', () => { + it('parses common markdown blocks into stable block types', 0, () => { + const blocks = MarkdownParser.parse('# Title\n\n- first\n- **second**\n\n> quote\n\n```ts\nconst value = 1;\n```\n\n| A | B |\n| --- | --- |\n| 1 | 2 |'); + + expect(blocks.length).assertEqual(5); + expect(blocks[0].type).assertEqual('heading'); + expect(blocks[0].level).assertEqual(1); + expect(blocks[1].type).assertEqual('list'); + expect(blocks[1].items.length).assertEqual(2); + expect(blocks[1].items[1].text).assertEqual('second'); + expect(blocks[2].type).assertEqual('quote'); + expect(blocks[3].type).assertEqual('code'); + expect(blocks[3].language).assertEqual('ts'); + expect(blocks[4].type).assertEqual('table'); + }); + + it('formats inline code, emphasis, and links without markdown control text', 0, () => { + const blocks = MarkdownParser.parse('Use `pnpm test`, **bold text**, and [docs](https://example.test).'); + + expect(blocks.length).assertEqual(1); + expect(blocks[0].type).assertEqual('paragraph'); + expect(blocks[0].text).assertEqual('Use pnpm test, bold text, and docs (https://example.test).'); + expect(blocks[0].inlines.length).assertEqual(7); + expect(blocks[0].inlines[1].type).assertEqual('code'); + expect(blocks[0].inlines[3].type).assertEqual('strong'); + expect(blocks[0].inlines[5].type).assertEqual('link'); + expect(blocks[0].inlines[5].url).assertEqual('https://example.test'); + }); + }); + + describe('GeneralChatPageState', () => { + it('projects configuration, busy state, and status text', 0, () => { + const state = new GeneralChatPageState(); + state.setConfiguration( + 'https://api.openbitfun.com', + 'deepseek-v4-flash', + true, + GeneralChatServiceState.Ready + ); + state.setBusy(true); + state.setStatus('Generating'); + + expect(state.apiUrl).assertEqual('https://api.openbitfun.com'); + expect(state.modelName).assertEqual('deepseek-v4-flash'); + expect(state.hasApiKey).assertTrue(); + expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); + expect(state.isBusy).assertTrue(); + expect(state.statusText).assertEqual('Generating'); + }); + + it('owns a copied session list and returns it in recent order', 0, () => { + const state = new GeneralChatPageState(); + const older = remoteSession('chat-older', 'Older chat'); + const newer = remoteSession('chat-newer', 'Newer chat'); + older.updatedAt = '2026-07-20T00:00:00.000Z'; + newer.updatedAt = '2026-07-21T00:00:00.000Z'; + const sessions = [older, newer]; + + state.setSessions(sessions); + sessions.length = 0; + + const recent = state.recentSessions(); + expect(state.sessions.length).assertEqual(2); + expect(recent[0].id).assertEqual('chat-newer'); + expect(recent[1].id).assertEqual('chat-older'); + }); + + it('owns active session and timeline projection copies', 0, () => { + const state = new GeneralChatPageState(); + const userMessage = chatMessage('user-1', 'user', 'Plan the refactor'); + const pendingMessage = chatMessage('pending-1', 'user', 'Retry this'); + const activeTurn = activeChatMessage('turn-1', 'Working'); + const timelineItems: ChatTimelineItem[] = [{ + id: 'message-user-1', + type: 'user_message', + message: userMessage, + isStreaming: false, + isFinalizing: false + }]; + + state.setActiveSession({ + sessionId: 'chat-1', + title: 'Architecture', + workspacePath: '', + agentType: 'chat' + }); + state.setTimelineProjection([userMessage], [pendingMessage], activeTurn, true, timelineItems); + timelineItems.length = 0; + + expect(state.activeSession.sessionId).assertEqual('chat-1'); + expect(state.activeSession.agentType).assertEqual('chat'); + expect(state.persistedMessages.length).assertEqual(1); + expect(state.optimisticMessages.length).assertEqual(1); + expect(state.timelineItems.length).assertEqual(1); + expect(state.hasMoreMessages).assertTrue(); + expect(state.hasRunningActiveTurn()).assertTrue(); + expect(state.latestUserMessageText()).assertEqual('Retry this'); + }); + + it('copies nested session projection and replaces streaming turn snapshots', 0, () => { + const state = new GeneralChatPageState(); + const session: SessionSummary = { + sessionId: 'chat-nested-1', + title: 'Original title', + workspacePath: '/must-not-leak', + agentType: 'code', + initialTurnId: 'turn-initial' + }; + const firstTurn = activeChatMessage('turn-stream-1', 'Hel', 'active', 1); + const secondTurn = activeChatMessage('turn-stream-1', 'Hello', 'active', 2); + + state.setActiveSession(session); + session.sessionId = 'mutated-session'; + session.title = 'Mutated title'; + + state.setTimelineProjection([], [], firstTurn, false, []); + firstTurn.text = 'mutated first turn'; + state.setTimelineProjection([], [], secondTurn, false, []); + + expect(state.activeSession.sessionId).assertEqual('chat-nested-1'); + expect(state.activeSession.title).assertEqual('Original title'); + expect(state.activeSession.agentType).assertEqual('chat'); + expect(state.activeSession.workspacePath).assertEqual('/must-not-leak'); + expect(state.activeTurnMessage.text).assertEqual('Hello'); + expect(state.activeTurnMessage.renderVersion || 0).assertEqual(2); + expect(state.hasRunningActiveTurn()).assertTrue(); + }); + + it('owns ordinary chat composer projection', 0, () => { + const state = new GeneralChatPageState(); + const images = [selectedImage('general-image-1')]; + + state.setChatInput('Draft text'); + state.setSelectedImages(images); + state.setVoiceListening(true); + images.length = 0; + + expect(state.chatInput).assertEqual('Draft text'); + expect(state.selectedImages.length).assertEqual(1); + expect(state.isVoiceListening).assertTrue(); + + state.removeSelectedImage('general-image-1'); + expect(state.selectedImages.length).assertEqual(0); + + state.clearComposer(); + state.setVoiceListening(false); + expect(state.chatInput).assertEqual(''); + expect(state.selectedImages.length).assertEqual(0); + expect(state.isVoiceListening).assertFalse(); + }); + }); + + describe('RemotePageState', () => { + it('projects connection summary and filters visible sessions', 0, () => { + const state = new RemotePageState(); + state.setDesktopIdentity('Developer Mac', 'desktop-1'); + state.setPairingInput('https://relay.example.com/#/pair?room=abc', 'harmony-device', true); + state.setAuthenticatedUserId('desktop-user'); + state.setStatusText('Connected'); + state.setConnectionState('connected'); + state.setConnectionFailureKind(''); + state.setBusy(true); + state.setWorkspace('BitFun', '/workspace/BitFun', 'default', 'main', 'normal'); + state.setSessions([ + remoteSession('session-1', '/canvas draw architecture'), + remoteSession('session-2', 'Write release notes'), + remoteSession('session-3', '/canvas archived', 'archived'), + remoteSession('', '/canvas invalid') + ], true); + state.setQuery('CANVAS'); + + const visible = state.visibleSessions(); + expect(state.desktopName).assertEqual('Developer Mac'); + expect(state.desktopId).assertEqual('desktop-1'); + expect(state.remoteUrl).assertEqual('https://relay.example.com/#/pair?room=abc'); + expect(state.userId).assertEqual('harmony-device'); + expect(state.authenticatedUserId).assertEqual('desktop-user'); + expect(state.statusText).assertEqual('Connected'); + expect(state.connectionState).assertEqual('connected'); + expect(state.connectionFailureKind).assertEqual(''); + expect(state.isBusy).assertTrue(); + expect(state.showRemoteUrlInput).assertTrue(); + expect(state.workspacePath).assertEqual('/workspace/BitFun'); + expect(state.workspaceBranch).assertEqual('main'); + expect(state.workspaceKind).assertEqual('normal'); + expect(state.assistantId).assertEqual('default'); + expect(state.sessions.length).assertEqual(4); + expect(state.hasMoreSessions).assertTrue(); + expect(visible.length).assertEqual(1); + expect(visible[0].id).assertEqual('session-1'); + }); + + it('clears loading errors on success and clears only list state on reset', 0, () => { + const state = new RemotePageState(); + state.setDesktopName('Developer Mac'); + state.setWorkspace('BitFun', '/workspace/BitFun', 'default'); + state.setQuery('canvas'); + state.setLoading(true); + state.setError('network failed'); + + expect(state.isLoadingSessions).assertFalse(); + expect(state.sessionErrorText).assertEqual('network failed'); + + state.setSessions([remoteSession('session-1', '/canvas')], false); + expect(state.sessionErrorText).assertEqual(''); + state.clear(); + + expect(state.sessions.length).assertEqual(0); + expect(state.hasMoreSessions).assertFalse(); + expect(state.isLoadingSessions).assertFalse(); + expect(state.sessionQuery).assertEqual('canvas'); + expect(state.desktopName).assertEqual('Developer Mac'); + expect(state.workspaceName).assertEqual('BitFun'); + }); + + it('owns active remote session, timeline projection, and model catalog', 0, () => { + const state = new RemotePageState(); + const userMessage = chatMessage('remote-user-1', 'user', 'Run tests'); + const activeTurn = activeChatMessage('remote-turn-1', 'Running cargo test'); + const timelineItems: ChatTimelineItem[] = [{ + id: 'active-remote-turn-1-0', + type: 'assistant_live_turn', + message: activeTurn, + isStreaming: true, + isFinalizing: false + }]; + const modelCatalog: RemoteModelCatalog = { + version: 2, + models: [{ + id: 'model-a', + name: 'Model A', + provider: 'local', + base_url: 'https://model.example.com', + model_name: 'model-a', + enabled: true, + capabilities: ['code'] + }], + default_models: { primary: 'model-a' }, + session_model_id: 'model-a' + }; + + state.setActiveSession({ + sessionId: 'remote-1', + title: 'Remote task', + workspacePath: '/workspace/BitFun', + agentType: 'code' + }); + state.setTimelineProjection([userMessage], [], activeTurn, false, timelineItems); + state.setModelCatalog(modelCatalog, 'model-a'); + timelineItems.length = 0; + + expect(state.activeSession.sessionId).assertEqual('remote-1'); + expect(state.activeSession.workspacePath).assertEqual('/workspace/BitFun'); + expect(state.timelineItems.length).assertEqual(1); + expect(state.hasRunningActiveTurn()).assertTrue(); + expect(state.modelCatalog.version).assertEqual(2); + expect(state.selectedModelId).assertEqual('model-a'); + }); + + it('copies nested remote session projection and replaces streaming turn snapshots', 0, () => { + const state = new RemotePageState(); + const session: SessionSummary = { + sessionId: 'remote-nested-1', + title: 'Remote original', + workspacePath: '/workspace/BitFun', + agentType: 'code', + initialTurnId: 'remote-turn-initial' + }; + const firstTurn = activeChatMessage('remote-stream-1', 'Run', 'active', 1); + const secondTurn = activeChatMessage('remote-stream-1', 'Run tests', 'active', 2); + const catalog = remoteModelCatalog('model-a'); + + state.setActiveSession(session); + session.sessionId = 'mutated-remote'; + session.workspacePath = '/mutated'; + + state.setTimelineProjection([], [], firstTurn, false, []); + firstTurn.text = 'mutated remote turn'; + state.setTimelineProjection([], [], secondTurn, false, []); + state.setModelCatalog(catalog, 'model-a'); + catalog.models.length = 0; + catalog.session_model_id = 'model-b'; + + expect(state.activeSession.sessionId).assertEqual('remote-nested-1'); + expect(state.activeSession.workspacePath).assertEqual('/workspace/BitFun'); + expect(state.activeSession.agentType).assertEqual('code'); + expect(state.activeTurnMessage.text).assertEqual('Run tests'); + expect(state.activeTurnMessage.renderVersion || 0).assertEqual(2); + expect(state.modelCatalog.models.length).assertEqual(2); + expect(state.modelCatalog.session_model_id || '').assertEqual('model-a'); + expect(state.selectedModelId).assertEqual('model-a'); + expect(state.hasRunningActiveTurn()).assertTrue(); + }); + + it('owns remote download status projection', 0, () => { + const state = new RemotePageState(); + + state.setDownloadStatus('/workspace/BitFun/README.md', '', '10 B / 20 B'); + expect(state.downloadingFilePath).assertEqual('/workspace/BitFun/README.md'); + expect(state.downloadedFilePath).assertEqual(''); + expect(state.fileDownloadStatus).assertEqual('10 B / 20 B'); + + state.setDownloadStatus('/workspace/BitFun/README.md', '/workspace/BitFun/README.md', '下载完成 README.md · 20 B'); + state.clearDownloadingFilePath(); + expect(state.downloadingFilePath).assertEqual(''); + expect(state.downloadedFilePath).assertEqual('/workspace/BitFun/README.md'); + expect(state.fileDownloadStatus).assertEqual('下载完成 README.md · 20 B'); + + state.clearDownloadStatus(); + expect(state.downloadingFilePath).assertEqual(''); + expect(state.downloadedFilePath).assertEqual(''); + expect(state.fileDownloadStatus).assertEqual(''); + }); + + it('owns remote composer attachments and voice projection', 0, () => { + const state = new RemotePageState(); + const first = selectedImage('remote-image-1'); + const second = selectedImage('remote-image-2'); + const images = [first]; + + state.setChatInput('/canvas draw'); + state.setSelectedImages(images); + state.addSelectedImages([second]); + state.setVoiceListening(true); + images.length = 0; + + expect(state.chatInput).assertEqual('/canvas draw'); + expect(state.selectedImages.length).assertEqual(2); + expect(state.selectedImages[1].id).assertEqual('remote-image-2'); + expect(state.isVoiceListening).assertTrue(); + + state.removeSelectedImage('remote-image-1'); + expect(state.selectedImages.length).assertEqual(1); + expect(state.selectedImages[0].id).assertEqual('remote-image-2'); + + state.clearComposer(); + state.setVoiceListening(false); + expect(state.chatInput).assertEqual(''); + expect(state.selectedImages.length).assertEqual(0); + expect(state.isVoiceListening).assertFalse(); + }); + + it('owns copied workspace and assistant picker projections', 0, () => { + const state = new RemotePageState(); + const workspaces: RecentWorkspaceEntry[] = [{ + path: '/workspace/BitFun', + name: 'BitFun', + lastOpened: '2026-07-21T00:00:00.000Z', + workspaceKind: 'normal' + }]; + const assistants: AssistantEntry[] = [{ + path: '/workspace/BitFun/.bitfun/assistants/default.md', + name: 'Default', + assistant_id: 'default' + }]; + + state.setRecentWorkspaces(workspaces); + state.setAssistants(assistants); + state.setWorkspacePickerVisible(true); + state.setAssistantPickerVisible(true); + workspaces.length = 0; + assistants.length = 0; + + expect(state.recentWorkspaces.length).assertEqual(1); + expect(state.assistants.length).assertEqual(1); + expect(state.showWorkspacePicker).assertTrue(); + expect(state.showAssistantPicker).assertTrue(); + + state.clearWorkspaceActions(); + expect(state.recentWorkspaces.length).assertEqual(0); + expect(state.assistants.length).assertEqual(0); + expect(state.showWorkspacePicker).assertFalse(); + expect(state.showAssistantPicker).assertFalse(); + }); + }); + + describe('ConversationViewState', () => { + it('projects remote and general state without page-level branching', 0, () => { + const remote = new RemotePageState(); + remote.setConnectionState('connected'); + remote.setChatInput('remote draft'); + remote.setActiveSession({ + sessionId: 'remote-session', title: 'Remote', workspacePath: '/repo', agentType: 'code' + }); + const general = new GeneralChatPageState(); + general.setChatInput('general draft'); + general.setActiveSession({ + sessionId: 'general-session', title: 'General', workspacePath: '', agentType: 'chat' + }); + + const remoteProjection = ConversationViewState.project(AppRoute.RemoteChat, remote, general, ''); + expect(remoteProjection.surface).assertEqual(ChatSurface.Remote); + expect(remoteProjection.chatInput).assertEqual('remote draft'); + expect(remoteProjection.activeSession.sessionId).assertEqual('remote-session'); + + const generalProjection = ConversationViewState.project(AppRoute.ChatHome, remote, general, 'Configure model'); + expect(generalProjection.surface).assertEqual(ChatSurface.General); + expect(generalProjection.chatInput).assertEqual('general draft'); + expect(generalProjection.inlineStatusText).assertEqual('Configure model'); + }); + }); + + describe('RemoteWorkspaceCoordinator', () => { + it('deduplicates sessions discovered across recent workspaces', 0, async () => { + const source = new FakeRemoteWorkspaceDataSource(); + source.sessionsByPath.set('/one', [ + remoteSession('shared-session', 'Shared'), + remoteSession('one-session', 'One') + ]); + source.sessionsByPath.set('/two', [ + remoteSession('shared-session', 'Shared again'), + remoteSession('two-session', 'Two') + ]); + const coordinator = new RemoteWorkspaceCoordinator(source); + + const sessions = await coordinator.sessionsForWorkspaces(['/one', '/two']); + expect(sessions.length).assertEqual(3); + expect(sessions[0].id).assertEqual('shared-session'); + expect(sessions[1].id).assertEqual('one-session'); + expect(sessions[2].id).assertEqual('two-session'); + }); + }); + + describe('ProductSurfaceIsolation', () => { + it('keeps ordinary chat and remote chat page state isolated', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const generalMessage = chatMessage('general-user-1', 'user', 'Local chat only'); + const remoteMessage = chatMessage('remote-user-1', 'user', 'Remote task only'); + + general.setActiveSession({ + sessionId: 'general-1', + title: 'General chat', + workspacePath: '/should-not-leak', + agentType: 'code' + }); + remote.setActiveSession({ + sessionId: 'remote-1', + title: 'Remote chat', + workspacePath: '/workspace/BitFun', + agentType: 'code' + }); + general.setTimelineProjection([generalMessage], [], RemoteUiState.emptyActiveTurn(), false, [{ + id: 'general-item-1', + type: 'user_message', + message: generalMessage, + isStreaming: false, + isFinalizing: false + }]); + remote.setTimelineProjection([remoteMessage], [], RemoteUiState.emptyActiveTurn(), false, [{ + id: 'remote-item-1', + type: 'user_message', + message: remoteMessage, + isStreaming: false, + isFinalizing: false + }]); + + expect(general.activeSession.agentType).assertEqual('chat'); + expect(remote.activeSession.agentType).assertEqual('code'); + expect(general.persistedMessages[0].id).assertEqual('general-user-1'); + expect(remote.persistedMessages[0].id).assertEqual('remote-user-1'); + + general.clearActiveSession(); + + expect(general.activeSession.sessionId).assertEqual(''); + expect(general.persistedMessages.length).assertEqual(0); + expect(remote.activeSession.sessionId).assertEqual('remote-1'); + expect(remote.activeSession.workspacePath).assertEqual('/workspace/BitFun'); + expect(remote.persistedMessages.length).assertEqual(1); + }); + + it('keeps streaming turn projections isolated across product surfaces', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const generalTurn = activeChatMessage('general-live-1', 'General partial', 'active', 1); + const remoteTurn = activeChatMessage('remote-live-1', 'Remote partial', 'active', 1); + + general.setTimelineProjection([], [], generalTurn, false, [{ + id: 'general-live-item', + type: 'assistant_live_turn', + message: generalTurn, + isStreaming: true, + isFinalizing: false + }]); + remote.setTimelineProjection([], [], remoteTurn, false, [{ + id: 'remote-live-item', + type: 'assistant_live_turn', + message: remoteTurn, + isStreaming: true, + isFinalizing: false + }]); + + expect(general.activeTurnMessage.id).assertEqual('active-general-live-1'); + expect(remote.activeTurnMessage.id).assertEqual('active-remote-live-1'); + expect(general.timelineItems[0].id).assertEqual('general-live-item'); + expect(remote.timelineItems[0].id).assertEqual('remote-live-item'); + + general.clearTimeline(); + + expect(general.hasRunningActiveTurn()).assertFalse(); + expect(general.timelineItems.length).assertEqual(0); + expect(remote.hasRunningActiveTurn()).assertTrue(); + expect(remote.activeTurnMessage.text).assertEqual('Remote partial'); + expect(remote.timelineItems.length).assertEqual(1); + }); + }); +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets new file mode 100644 index 0000000000..3be9a16c59 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -0,0 +1,741 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; +import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; +import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; +import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; +import { ConversationEvent } from '../main/ets/services/ConversationEvent'; +import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; +import { Encoding } from '../main/ets/services/Encoding'; +import { + GeneralChatDraftStore, + GeneralChatLocalStore, + GeneralChatSendResult, + GeneralChatStreamCallbacks, + GeneralChatStreamObserver +} from '../main/ets/services/general-chat/GeneralChatPort'; +import { GeneralChatApiClient } from '../main/ets/services/general-chat/GeneralChatApiClient'; +import { GeneralChatBootstrapController } from '../main/ets/services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ets/services/general-chat/GeneralChatCommandController'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigValidator +} from '../main/ets/services/general-chat/GeneralChatConfigStore'; +import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; +import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; +import { + GeneralChatHttpHeaders, + GeneralChatHttpMethod, + GeneralChatHttpResponse, + GeneralChatHttpTransport, + GeneralChatTokenProvider +} from '../main/ets/services/general-chat/GeneralChatHttpTransport'; +import { GeneralChatRepository } from '../main/ets/services/general-chat/GeneralChatRepository'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../main/ets/services/general-chat/GeneralChatServiceState'; +import { GeneralChatStreamCoordinator } from '../main/ets/services/general-chat/GeneralChatStreamCoordinator'; +import { GeneralChatStreamLifecycleController } from '../main/ets/services/general-chat/GeneralChatStreamLifecycleController'; +import { + ModelProviderGeneralChatAdapter, + ModelProviderMessage, + ModelProviderProtocol +} from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; +import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; +import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; +import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; +import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; +import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; +import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; +import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler +} from '../main/ets/services/RemoteFileDownloadController'; +import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; +import { + RemoteModelClient, + RemoteModelController, + RemoteModelPreferenceStore +} from '../main/ets/services/RemoteModelController'; +import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; +import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; +import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; +import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; +import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; +import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; +import { RemoteUiState } from '../main/ets/services/RemoteUiState'; +import { + VoiceInputLifecycleCallbacks, + VoiceInputLifecycleController, + VoiceInputRouteSnapshot +} from '../main/ets/services/VoiceInputLifecycleController'; +import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; +import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES +} from '../main/ets/pages/components/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + AppNavigationBackAction, + AppNavigationPathSpec, + AppRoute, + AppRouteContract +} from '../main/ets/pages/navigation/AppRouteContract'; +import { TimeFormat } from '../main/ets/services/TimeFormat'; +import { X25519 } from '../main/ets/services/X25519'; +import { + ChatMessage, + FileInfo, + ImageAttachment, + PollSessionResult, + RecentWorkspaceEntry, + AssistantEntry, + CreateSessionOptions, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteModelCatalog, + ReadFileResult, + RemoteSession, + SelectedImageAttachment, + SessionListResult, + SessionMessagesResult, + SessionSummary, + WorkspaceInfo +} from '../main/ets/model/RemoteModels'; + +import { + ToolInputFixture, + CryptoImageFixture, + CryptoCommandFixture, + CryptoSimpleCommandFixture, + GeneralChatRecordedRequest, + FakeRemoteWorkspaceDataSource, + FakeRemoteCryptoCipher, + hexToBytes, + bytesToHex, + fixedRandomBytes, + expectCommandJson, + connectedPeers, + chatMessage, + remoteSession, + selectedImage, + remoteModelCatalog, + activeChatMessage, + chatMessageWithImage, + imageAttachment, + pollResult, + delay, + FakePollSessionManager, + BlockingPollSessionManager, + FakeGeneralChatLocalStore, + FailingGeneralChatAdapter, + PartialFailingGeneralChatAdapter, + FakeGeneralChatTokenProvider, + FakeGeneralChatHttpTransport, + FakeRemoteHeartbeatScheduler, + FakeGeneralChatDraftScheduler, + FakeVoiceInputLifecycleService, + FakeRemoteFileDownloadScheduler, + FakeRemoteFileDownloadClient, + RemoteDownloadStatusRecord, + FakeRemoteToolActionClient, + FakeRemoteModelClient, + FakeRemoteModelPreferenceStore, + RemoteModelProjectionRecord, + FakeRemoteSessionClient, + RemoteSessionProjectionRecord, + FakeRemoteChatCommandClient, + RemoteChatMessagesProjectionRecord, + FakeGeneralChatCommandClient, + FakeGeneralChatConfigStore +} from './LocalTestFixtures'; + +export default function lifecycleUnitTest() { + describe('AppShellState', () => { + it('owns global sidebar and sheet visibility', 0, () => { + const state = new AppShellState(); + + state.setSidebarVisible(true); + state.setSettingsVisible(true); + state.setConnectSheetVisible(true); + + expect(state.showSidebar).assertTrue(); + expect(state.showSettings).assertTrue(); + expect(state.showConnectSheet).assertTrue(); + + state.closeGlobalSurfaces(); + expect(state.showSidebar).assertFalse(); + expect(state.showSettings).assertFalse(); + expect(state.showConnectSheet).assertFalse(); + }); + }); + + describe('AsyncLifecycleGate', () => { + it('invalidates earlier asynchronous generations', 0, () => { + const gate = new AsyncLifecycleGate(); + const first = gate.begin(); + expect(gate.isCurrent(first)).assertTrue(); + + const second = gate.begin(); + expect(gate.isCurrent(first)).assertFalse(); + expect(gate.isCurrent(second)).assertTrue(); + + gate.invalidate(); + expect(gate.isCurrent(second)).assertFalse(); + }); + }); + + describe('RemoteHeartbeatController', () => { + it('owns heartbeat timer restart, stop, and tick dispatch', 0, () => { + const scheduler = new FakeRemoteHeartbeatScheduler(); + let tickCount = 0; + const controller = new RemoteHeartbeatController(() => { + tickCount += 1; + }, 15000, scheduler); + + controller.start(); + expect(controller.isRunning()).assertTrue(); + expect(scheduler.intervals.get(1)).assertEqual(15000); + scheduler.tick(1); + expect(tickCount).assertEqual(1); + + controller.start(); + expect(scheduler.clearedIds.length).assertEqual(1); + expect(scheduler.clearedIds[0]).assertEqual(1); + expect(scheduler.callbacks.has(1)).assertFalse(); + expect(scheduler.callbacks.has(2)).assertTrue(); + + controller.stop(); + controller.stop(); + expect(controller.isRunning()).assertFalse(); + expect(scheduler.clearedIds.length).assertEqual(2); + expect(scheduler.clearedIds[1]).assertEqual(2); + }); + }); + + describe('RemoteActivityLifecycleController', () => { + it('owns remote heartbeat lifecycle while preserving tick dispatch', 0, () => { + const scheduler = new FakeRemoteHeartbeatScheduler(); + let tickCount = 0; + const controller = new RemoteActivityLifecycleController(() => { + tickCount += 1; + }, 15000, scheduler); + + controller.startHeartbeat(); + expect(controller.isHeartbeatRunning()).assertTrue(); + expect(scheduler.intervals.get(1)).assertEqual(15000); + scheduler.tick(1); + expect(tickCount).assertEqual(1); + + controller.startHeartbeat(); + expect(scheduler.clearedIds[0]).assertEqual(1); + expect(scheduler.callbacks.has(1)).assertFalse(); + expect(scheduler.callbacks.has(2)).assertTrue(); + + controller.stopHeartbeat(); + controller.stopHeartbeat(); + expect(controller.isHeartbeatRunning()).assertFalse(); + expect(scheduler.clearedIds.length).assertEqual(2); + expect(scheduler.clearedIds[1]).assertEqual(2); + }); + }); + + describe('RemoteChatPollingLifecycleController', () => { + it('starts the active remote chat poller and forwards snapshots', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + manager.results = [pollResult({ + version: 5, + changed: true, + sessionState: 'idle', + title: 'Updated Session', + newMessages: [chatMessage('message-5', 'assistant', 'Done')], + totalMessageCount: 5 + })]; + const controller = new RemoteChatPollingLifecycleController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + controller.startActiveSession({ + sessionId: 'session-1', + cursor: { + pollVersion: 4, + knownMessageCount: 4, + knownModelCatalogVersion: 0 + } + }); + await delay(20); + controller.stop(); + + expect(snapshots.length).assertEqual(1); + expect(snapshots[0].sessionId).assertEqual('session-1'); + expect(snapshots[0].cursor.pollVersion).assertEqual(5); + expect(snapshots[0].cursor.knownMessageCount).assertEqual(5); + expect(snapshots[0].newMessages[0].id).assertEqual('message-5'); + }); + + it('ignores empty active session starts', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + manager.results = [pollResult({ + version: 1, + changed: false, + sessionState: 'idle', + title: 'Ignored', + newMessages: [], + totalMessageCount: 0 + })]; + const controller = new RemoteChatPollingLifecycleController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + controller.startActiveSession({ + sessionId: '', + cursor: { + pollVersion: 0, + knownMessageCount: 0, + knownModelCatalogVersion: 0 + } + }); + await delay(20); + + expect(snapshots.length).assertEqual(0); + expect(manager.results.length).assertEqual(1); + }); + }); + + describe('VoiceInputLifecycleController', () => { + it('owns start stop guard and callback routing for visible voice input', 0, async () => { + const service = new FakeVoiceInputLifecycleService(); + let inputText = ''; + let statusText = ''; + let clearAllCount = 0; + const inputEvents: string[] = []; + const listeningEvents: string[] = []; + const statuses: string[] = []; + const errors: string[] = []; + const callbacks: VoiceInputLifecycleCallbacks = { + currentInputText: (): string => inputText, + currentStatusText: (): string => statusText, + onInputText: (routeId: string, text: string): void => { + inputText = text; + inputEvents.push(`${routeId}:${text}`); + }, + onListening: (routeId: string, isListening: boolean): void => { + listeningEvents.push(`${routeId}:${isListening ? '1' : '0'}`); + }, + onStatusText: (nextStatusText: string): void => { + statusText = nextStatusText; + statuses.push(nextStatusText); + }, + onError: (message: string): void => { + statusText = message; + errors.push(message); + } + }; + const controller = new VoiceInputLifecycleController(service, callbacks); + const snapshot: VoiceInputRouteSnapshot = { + routeId: 'GeneralChat', + isListening: false, + isBusy: false, + inputText: '', + selectedImageCount: 0 + }; + + expect(await controller.toggle({} as Context, snapshot)).assertTrue(); + service.emitText('recognized text', true); + expect(service.startRequests).assertEqual(1); + expect(inputEvents[0]).assertEqual('GeneralChat:recognized text'); + expect(listeningEvents[0]).assertEqual('GeneralChat:1'); + expect(statuses.indexOf(RemoteI18n.t('status.voiceStarting')) >= 0).assertTrue(); + expect(statuses.indexOf(RemoteI18n.t('status.voiceListening')) >= 0).assertTrue(); + expect(statuses.indexOf(RemoteI18n.t('status.voiceRecognized')) >= 0).assertTrue(); + + expect(await controller.stop({ + routeId: 'GeneralChat', + isListening: true, + isBusy: false, + inputText, + selectedImageCount: 0 + }, true)).assertTrue(); + expect(service.stopRequests).assertEqual(1); + expect(listeningEvents[listeningEvents.length - 1]).assertEqual('GeneralChat:0'); + + expect(await controller.toggle({} as Context, { + routeId: 'GeneralChat', + isListening: false, + isBusy: true, + inputText: '', + selectedImageCount: 0 + })).assertFalse(); + expect(service.startRequests).assertEqual(1); + + service.shouldFailStart = true; + inputText = ''; + expect(await controller.toggle({} as Context, snapshot)).assertFalse(); + expect(errors[0]).assertEqual('Expected voice start failure.'); + + await controller.cancel('GeneralChat', () => { + clearAllCount += 1; + }); + expect(service.cancelRequests).assertEqual(1); + expect(clearAllCount).assertEqual(1); + }); + }); + + describe('GeneralChatDraftController', () => { + it('owns debounced draft save timer and immediate persistence paths', 0, async () => { + const store = new FakeGeneralChatLocalStore(); + const scheduler = new FakeGeneralChatDraftScheduler(); + const controller = new GeneralChatDraftController(store, 250, (_err: Error) => {}, scheduler); + + controller.scheduleSave('draft-1', 'first'); + expect(controller.isPending()).assertTrue(); + expect(scheduler.delays.get(1)).assertEqual(250); + scheduler.tick(1); + await Promise.resolve(); + expect(controller.isPending()).assertFalse(); + expect(await store.loadDraft('draft-1')).assertEqual('first'); + + controller.scheduleSave('draft-1', 'old'); + controller.scheduleSave('draft-1', 'new'); + expect(scheduler.clearedIds[0]).assertEqual(2); + scheduler.tick(2); + await Promise.resolve(); + expect(await store.loadDraft('draft-1')).assertEqual('first'); + scheduler.tick(3); + await Promise.resolve(); + expect(await store.loadDraft('draft-1')).assertEqual('new'); + + controller.scheduleSave('draft-1', 'pending'); + controller.persistNow('draft-1', 'now'); + await Promise.resolve(); + expect(scheduler.clearedIds[1]).assertEqual(4); + expect(await store.loadDraft('draft-1')).assertEqual('now'); + + await controller.clearNow('draft-1'); + expect(await store.loadDraft('draft-1')).assertEqual(''); + await store.saveDraft('draft-1', 'restored'); + expect(await controller.restore('draft-1')).assertEqual('restored'); + }); + }); + + describe('GeneralChatDraftLifecycleController', () => { + it('routes home and visible draft lifecycle operations through the draft controller', 0, async () => { + const store = new FakeGeneralChatLocalStore(); + const scheduler = new FakeGeneralChatDraftScheduler(); + const draftController = new GeneralChatDraftController(store, 250, (_err: Error) => {}, scheduler); + let visibleDraftId = ''; + const lifecycle = new GeneralChatDraftLifecycleController( + draftController, + 'home-draft', + (): string => visibleDraftId + ); + + lifecycle.scheduleVisible('ignored'); + expect(draftController.isPending()).assertFalse(); + + visibleDraftId = 'chat-1'; + lifecycle.scheduleVisible('draft text'); + expect(draftController.isPending()).assertTrue(); + scheduler.tick(1); + await Promise.resolve(); + expect(await lifecycle.restore('chat-1')).assertEqual('draft text'); + + lifecycle.persistVisible('persisted text'); + await Promise.resolve(); + expect(await lifecycle.restore('chat-1')).assertEqual('persisted text'); + + await store.saveDraft('home-draft', 'home text'); + expect(await lifecycle.restoreHome()).assertEqual('home text'); + await lifecycle.clearHomeNow(); + expect(await lifecycle.restoreHome()).assertEqual(''); + + lifecycle.scheduleVisible('pending'); + lifecycle.cancel(); + expect(draftController.isPending()).assertFalse(); + }); + }); + + describe('GeneralChatBootstrapController', () => { + it('restores configuration sessions and home draft through lifecycle collaborators', 0, async () => { + const configStore = new FakeGeneralChatConfigStore(); + const client = new FakeGeneralChatCommandClient(); + const localStore = new FakeGeneralChatLocalStore(); + const draftController = new GeneralChatDraftController(localStore); + const draftLifecycle = new GeneralChatDraftLifecycleController( + draftController, + 'home-draft', + (): string => 'home-draft' + ); + const restoredConfigs: GeneralChatConfigSnapshot[] = []; + const restoredDrafts: string[] = []; + const statuses: string[] = []; + await localStore.saveDraft('home-draft', 'saved home draft'); + const commandController = new GeneralChatCommandController(client, { + onSessions: (_sessions: RemoteSession[]) => {}, + onSessionPrepared: (_sessionId: string) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onMessagesLoaded: (_messages: ChatMessage[]) => {}, + onClearComposer: () => {}, + onChatInput: (_text: string) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onToast: (_statusText: string) => {} + }); + const bootstrap = new GeneralChatBootstrapController( + configStore, + commandController, + draftLifecycle, + { + onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { + restoredConfigs.push(snapshot); + }, + onHomeDraftRestored: (text: string) => { + restoredDrafts.push(text); + }, + onStatusText: (statusText: string) => { + statuses.push(statusText); + } + } + ); + + await bootstrap.restore({} as Context); + + expect(configStore.initRequests).assertEqual(1); + expect(client.initRequests).assertEqual(1); + expect(restoredConfigs[0].modelName).assertEqual('model-a'); + expect(restoredDrafts[0]).assertEqual('saved home draft'); + expect(statuses.length).assertEqual(0); + + client.shouldFail = true; + await bootstrap.restore({} as Context); + expect(statuses[0]).assertEqual(RemoteI18n.t('generalChat.localRestoreFailed')); + }); + }); + + describe('GeneralChatCommandController', () => { + it('opens and creates general chat sessions through page callbacks', 0, async () => { + const client = new FakeGeneralChatCommandClient(); + const activeSessions: SessionSummary[] = []; + const preparedSessions: string[] = []; + const openedRoutes: string[] = []; + const loadedMessages: ChatMessage[][] = []; + const chatInputs: string[] = []; + let clearComposerCount = 0; + const session = remoteSession('chat-1', 'Chat 1'); + session.agentType = 'chat'; + client.currentSessions = [session]; + client.messagesBySession.set('chat-1', [chatMessage('message-1', 'assistant', 'Welcome')]); + const controller = new GeneralChatCommandController(client, { + onSessions: (_items: RemoteSession[]) => {}, + onSessionPrepared: (sessionId: string) => { + preparedSessions.push(sessionId); + }, + onActiveSession: (activeSession: SessionSummary) => { + activeSessions.push(activeSession); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + loadedMessages.push(messages); + }, + onClearComposer: () => { + clearComposerCount += 1; + }, + onChatInput: (text: string) => { + chatInputs.push(text); + }, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onToast: (_statusText: string) => {} + }); + + const opened = await controller.openSession(session, false, async (_sessionId: string): Promise => { + return 'draft text'; + }, (sessionId: string): void => { + openedRoutes.push(`open:${sessionId}`); + }); + const created = await controller.createSession(' new prompt ', false, async (): Promise => { + openedRoutes.push('draft-cleared'); + }, (sessionId: string): void => { + openedRoutes.push(`create:${sessionId}`); + }); + + expect(opened).assertTrue(); + expect(created).assertTrue(); + expect(preparedSessions[0]).assertEqual('chat-1'); + expect(preparedSessions[1]).assertEqual('chat-created'); + expect(activeSessions[0].sessionId).assertEqual('chat-1'); + expect(activeSessions[1].sessionId).assertEqual('chat-created'); + expect(loadedMessages[0][0].id).assertEqual('message-1'); + expect(clearComposerCount).assertEqual(1); + expect(chatInputs[0]).assertEqual('draft text'); + expect(client.createRequests[0]).assertEqual('new prompt'); + expect(openedRoutes[0]).assertEqual('open:chat-1'); + expect(openedRoutes[1]).assertEqual('create:chat-created'); + expect(openedRoutes[2]).assertEqual('draft-cleared'); + }); + + it('handles delete archive and export session commands', 0, async () => { + const client = new FakeGeneralChatCommandClient(); + const sessions: RemoteSession[][] = []; + const statuses: string[] = []; + const busyEvents: boolean[] = []; + const toasts: string[] = []; + const exportedText: string[] = []; + const session = remoteSession('chat-1', 'Chat 1'); + session.agentType = 'chat'; + client.currentSessions = [session]; + client.messagesBySession.set('chat-1', [chatMessage('message-1', 'user', 'Hello')]); + const controller = new GeneralChatCommandController(client, { + onSessions: (items: RemoteSession[]) => { + sessions.push(items); + }, + onSessionPrepared: (_sessionId: string) => {}, + onActiveSession: (_activeSession: SessionSummary) => {}, + onMessagesLoaded: (_messages: ChatMessage[]) => {}, + onClearComposer: () => {}, + onChatInput: (_text: string) => {}, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onBusy: (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + onToast: (statusText: string) => { + toasts.push(statusText); + } + }); + + await controller.archiveSession(session, true, false); + await controller.exportSession(session, false, async (text: string): Promise => { + exportedText.push(text); + }); + await controller.deleteSession(session, false); + + expect(client.archiveRequests[0]).assertEqual('chat-1:1'); + expect(client.messageRequests[0]).assertEqual('chat-1'); + expect(exportedText[0].indexOf('Hello') >= 0).assertTrue(); + expect(client.deleteRequests[0]).assertEqual('chat-1'); + expect(sessions.length).assertEqual(2); + expect(sessions[sessions.length - 1].length).assertEqual(0); + expect(statuses.indexOf(RemoteI18n.t('status.sessionArchived')) >= 0).assertTrue(); + expect(statuses.indexOf(RemoteI18n.t('status.sessionExported')) >= 0).assertTrue(); + expect(statuses[statuses.length - 1]).assertEqual(RemoteI18n.t('status.sessionDeleted')); + expect(toasts.length).assertEqual(2); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[busyEvents.length - 1]).assertFalse(); + }); + + it('proxies send cancel and assistant persistence through the command boundary', 0, async () => { + const client = new FakeGeneralChatCommandClient(); + const sessions: RemoteSession[][] = []; + const userMessages: ChatMessage[] = []; + const deltas: string[] = []; + const controller = new GeneralChatCommandController(client, { + onSessions: (items: RemoteSession[]) => { + sessions.push(items); + }, + onSessionPrepared: (_sessionId: string) => {}, + onActiveSession: (_activeSession: SessionSummary) => {}, + onMessagesLoaded: (_messages: ChatMessage[]) => {}, + onClearComposer: () => {}, + onChatInput: (_text: string) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onToast: (_statusText: string) => {} + }); + + const result = await controller.sendMessage('chat-1', 'Hello', [selectedImage('image-1')], + new GeneralChatStreamCallbacks( + async (message: ChatMessage): Promise => { + userMessages.push(message); + }, + (delta: string): void => { + deltas.push(delta); + } + )); + const recorded = await controller.recordAssistantMessage('chat-1', result.assistantMessage); + controller.cancelCurrentRequest(); + + expect(client.sendRequests[0]).assertEqual('chat-1:Hello:1'); + expect(userMessages[0].id).assertEqual('sent-user'); + expect(deltas.join('')).assertEqual('Echo: Hello'); + expect(recorded).assertTrue(); + expect(client.recordedAssistantMessages[0]).assertEqual('chat-1:sent-assistant'); + expect(sessions.length).assertEqual(1); + expect(client.cancelCount).assertEqual(1); + }); + + it('renames active sessions and prepares retry message state', 0, async () => { + const client = new FakeGeneralChatCommandClient(); + const activeSessions: SessionSummary[] = []; + const loadedMessages: ChatMessage[][] = []; + const chatInputs: string[] = []; + const statuses: string[] = []; + const session = remoteSession('chat-1', 'Old title'); + session.agentType = 'chat'; + client.currentSessions = [session]; + client.messagesBySession.set('chat-1', [chatMessage('assistant-1', 'assistant', 'Recovered')]); + const controller = new GeneralChatCommandController(client, { + onSessions: (_items: RemoteSession[]) => {}, + onSessionPrepared: (_sessionId: string) => {}, + onActiveSession: (activeSession: SessionSummary) => { + activeSessions.push(activeSession); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + loadedMessages.push(messages); + }, + onClearComposer: () => {}, + onChatInput: (text: string) => { + chatInputs.push(text); + }, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onBusy: (_isBusy: boolean) => {}, + onToast: (_statusText: string) => {} + }); + + const renamed = await controller.renameActiveSession({ + sessionId: 'chat-1', + title: 'Old title', + workspacePath: '', + agentType: 'chat', + initialTurnId: 'turn-0' + }, ' New title '); + const prepared = await controller.retryMessage('chat-1', 'Retry text', false); + + expect(renamed).assertTrue(); + expect(client.renameRequests[0]).assertEqual('chat-1:New title'); + expect(activeSessions[0].title).assertEqual('New title'); + expect(activeSessions[0].initialTurnId || '').assertEqual('turn-0'); + expect(statuses[0]).assertEqual(RemoteI18n.t('status.titleUpdated')); + expect(prepared).assertTrue(); + expect(client.removeFailedRequests[0]).assertEqual('chat-1:Retry text'); + expect(loadedMessages[0][0].id).assertEqual('assistant-1'); + expect(chatInputs[0]).assertEqual('Retry text'); + }); + }); +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/List.test.ets b/src/apps/mobile/harmonyos/entry/src/test/List.test.ets index bb5b5c3731..612c160a73 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/List.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/List.test.ets @@ -1,5 +1,7 @@ import localUnitTest from './LocalUnit.test'; +import architectureUnitTest from './ArchitectureUnit.test'; export default function testsuite() { localUnitTest(); -} \ No newline at end of file + architectureUnitTest(); +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets new file mode 100644 index 0000000000..16d65fb2b6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -0,0 +1,1037 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; +import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; +import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; +import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; +import { ConversationEvent } from '../main/ets/services/ConversationEvent'; +import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; +import { Encoding } from '../main/ets/services/Encoding'; +import { + GeneralChatDraftStore, + GeneralChatLocalStore, + GeneralChatSendResult, + GeneralChatStreamCallbacks, + GeneralChatStreamObserver +} from '../main/ets/services/general-chat/GeneralChatPort'; +import { GeneralChatApiClient } from '../main/ets/services/general-chat/GeneralChatApiClient'; +import { GeneralChatBootstrapController } from '../main/ets/services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ets/services/general-chat/GeneralChatCommandController'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigValidator +} from '../main/ets/services/general-chat/GeneralChatConfigStore'; +import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; +import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; +import { + GeneralChatHttpHeaders, + GeneralChatHttpMethod, + GeneralChatHttpResponse, + GeneralChatHttpTransport, + GeneralChatTokenProvider +} from '../main/ets/services/general-chat/GeneralChatHttpTransport'; +import { GeneralChatRepository } from '../main/ets/services/general-chat/GeneralChatRepository'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../main/ets/services/general-chat/GeneralChatServiceState'; +import { GeneralChatStreamCoordinator } from '../main/ets/services/general-chat/GeneralChatStreamCoordinator'; +import { GeneralChatStreamLifecycleController } from '../main/ets/services/general-chat/GeneralChatStreamLifecycleController'; +import { + ModelProviderGeneralChatAdapter, + ModelProviderMessage, + ModelProviderProtocol +} from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; +import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; +import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; +import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; +import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; +import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; +import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; +import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler +} from '../main/ets/services/RemoteFileDownloadController'; +import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; +import { + RemoteModelClient, + RemoteModelController, + RemoteModelPreferenceStore +} from '../main/ets/services/RemoteModelController'; +import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; +import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; +import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; +import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; +import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; +import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; +import { RemoteUiState } from '../main/ets/services/RemoteUiState'; +import { + VoiceInputLifecycleCallbacks, + VoiceInputLifecycleController, + VoiceInputRouteSnapshot +} from '../main/ets/services/VoiceInputLifecycleController'; +import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; +import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES +} from '../main/ets/pages/components/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + AppNavigationBackAction, + AppNavigationPathSpec, + AppRoute, + AppRouteContract +} from '../main/ets/pages/navigation/AppRouteContract'; +import { TimeFormat } from '../main/ets/services/TimeFormat'; +import { X25519 } from '../main/ets/services/X25519'; +import { + ChatMessage, + FileInfo, + ImageAttachment, + PollSessionResult, + RecentWorkspaceEntry, + AssistantEntry, + CreateSessionOptions, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteModelCatalog, + ReadFileResult, + RemoteSession, + SelectedImageAttachment, + SessionListResult, + SessionMessagesResult, + SessionSummary, + WorkspaceInfo +} from '../main/ets/model/RemoteModels'; + +export interface ToolInputFixture { + safe: boolean; +} + +export interface CryptoImageFixture { + id: string; + data_url: string; + mime_type: string; +} + +export interface CryptoCommandFixture { + cmd: string; + session_id: string; + content: string; + agent_type: string; + image_contexts: CryptoImageFixture[]; +} + +export interface CryptoSimpleCommandFixture { + cmd: string; +} + +export interface GeneralChatRecordedRequest { + url: string; + method: GeneralChatHttpMethod; + authorization: string; + body: string; +} + +export class FakeRemoteWorkspaceDataSource implements RemoteWorkspaceDataSource { + recent: RecentWorkspaceEntry[] = []; + sessionsByPath: Map = new Map(); + + async listRecentWorkspaces(): Promise { + return this.recent; + } + + async setWorkspace(_path: string): Promise { + return { + name: 'workspace', path: '/workspace', hasWorkspace: true, + gitBranch: 'main', workspaceKind: 'normal', assistantId: '' + }; + } + + async listAssistants(): Promise { + return []; + } + + async setAssistant(_path: string): Promise { + return { + name: 'assistant', path: '/assistant', hasWorkspace: true, + gitBranch: '', workspaceKind: 'assistant', assistantId: 'assistant' + }; + } + + async listSessionsForWorkspace(path: string, _limit: number): Promise { + return this.sessionsByPath.get(path) || []; + } + + async ping(): Promise { + } + + reset(): void { + } +} + +export class FakeRemoteCryptoCipher implements RemoteCryptoCipher { + async encrypt(plainBytes: Uint8Array, key: Uint8Array, nonce: Uint8Array): Promise { + const out = new Uint8Array(plainBytes.length + 16); + for (let index = 0; index < plainBytes.length; index++) { + out[index] = plainBytes[index] ^ key[index % key.length] ^ nonce[index % nonce.length]; + } + for (let index = 0; index < 16; index++) { + out[plainBytes.length + index] = key[index] ^ nonce[index % nonce.length] ^ 165; + } + return out; + } + + async decrypt(encryptedBytes: Uint8Array, key: Uint8Array, nonce: Uint8Array): Promise { + if (encryptedBytes.length < 16) { + throw new Error('Encrypted payload is too short.'); + } + const plainLength = encryptedBytes.length - 16; + const out = new Uint8Array(plainLength); + for (let index = 0; index < plainLength; index++) { + out[index] = encryptedBytes[index] ^ key[index % key.length] ^ nonce[index % nonce.length]; + } + return out; + } +} + +export function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +export function bytesToHex(bytes: Uint8Array): string { + const alphabet = '0123456789abcdef'; + let text = ''; + for (let index = 0; index < bytes.length; index++) { + const value = bytes[index]; + text += alphabet.charAt((value >> 4) & 15) + alphabet.charAt(value & 15); + } + return text; +} + +export function fixedRandomBytes(size: number): Uint8Array { + const bytes = new Uint8Array(size); + for (let index = 0; index < size; index++) { + bytes[index] = (index * 17 + 29) & 255; + } + return bytes; +} + +export function expectCommandJson(command: Object, expected: string): void { + expect(JSON.stringify(command)).assertEqual(expected); +} + +export function connectedPeers(): RemoteCrypto[] { + const cipher = new FakeRemoteCryptoCipher(); + const alicePrivate = hexToBytes('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'); + const bobPrivate = hexToBytes('5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb'); + const alice = new RemoteCrypto({ + cipher, + keyPair: { + privateKey: alicePrivate, + publicKey: X25519.scalarMultBase(alicePrivate) + }, + randomBytes: fixedRandomBytes + }); + const bob = new RemoteCrypto({ + cipher, + keyPair: { + privateKey: bobPrivate, + publicKey: X25519.scalarMultBase(bobPrivate) + }, + randomBytes: fixedRandomBytes + }); + alice.deriveSharedKey(bob.getPublicKeyBase64()); + bob.deriveSharedKey(alice.getPublicKeyBase64()); + return [alice, bob]; +} + +export function chatMessage(id: string, role: string, text: string, status: string = 'completed'): ChatMessage { + return { + id, + role, + text, + status, + detail: '' + }; +} + +export function remoteSession(id: string, title: string, status: string = 'active'): RemoteSession { + return { + id, + title, + agentType: 'code', + status, + updatedAt: '2026-07-21T00:00:00.000Z', + createdAt: '2026-07-21T00:00:00.000Z', + messageCount: 1 + }; +} + +export function selectedImage(id: string): SelectedImageAttachment { + return { + id, + uri: `file:///tmp/${id}.png`, + name: `${id}.png`, + data_url: 'data:image/png;base64,AA==', + mime_type: 'image/png', + size: 2, + original_size: 2, + compressed: false + }; +} + +export function remoteModelCatalog(sessionModelId: string = ''): RemoteModelCatalog { + return { + version: 2, + models: [{ + id: 'model-a', + name: 'Model A', + provider: 'local', + base_url: 'https://model.example.com', + model_name: 'model-a', + enabled: true, + capabilities: ['code'] + }, { + id: 'model-b', + name: 'Model B', + provider: 'local', + base_url: 'https://model.example.com', + model_name: 'model-b', + enabled: true, + capabilities: ['code'] + }], + default_models: { primary: 'model-a' }, + session_model_id: sessionModelId + }; +} + +export function activeChatMessage(turnId: string, text: string, status: string = 'active', renderVersion: number = 0): ChatMessage { + const message = chatMessage(`active-${turnId}`, 'assistant', text, status); + message.turnId = turnId; + message.renderVersion = renderVersion; + return message; +} + +export function chatMessageWithImage(id: string, role: string, text: string, imageName: string, imageDataUrl: string): ChatMessage { + return { + id, + role, + text, + status: 'completed', + detail: '', + images: [imageAttachment(imageName, imageDataUrl)] + }; +} + +export function imageAttachment(name: string, dataUrl: string): ImageAttachment { + return { + name, + data_url: dataUrl + }; +} + +export function pollResult(options: PollSessionResult): PollSessionResult { + return options; +} + +export function delay(ms: number): Promise { + return new Promise((resolve: () => void) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +export class FakePollSessionManager extends RemoteSessionManager { + results: PollSessionResult[] = []; + + async pollSession( + _sessionId: string, + _sinceVersion: number, + _knownMessageCount: number, + _knownModelCatalogVersion: number + ): Promise { + const next = this.results.shift(); + if (!next) { + throw new Error('Missing fake poll result.'); + } + return next; + } +} + +export class BlockingPollSessionManager extends RemoteSessionManager { + private resolver?: (result: PollSessionResult) => void; + + async pollSession( + _sessionId: string, + _sinceVersion: number, + _knownMessageCount: number, + _knownModelCatalogVersion: number + ): Promise { + return new Promise((resolve: (result: PollSessionResult) => void) => { + this.resolver = resolve; + }); + } + + resolve(result: PollSessionResult): void { + if (this.resolver) { + const resolve = this.resolver; + this.resolver = undefined; + resolve(result); + } + } +} + +export class FakeGeneralChatLocalStore implements GeneralChatLocalStore, GeneralChatDraftStore { + sessions: RemoteSession[] = []; + messagesBySession: Map = new Map(); + drafts: Map = new Map(); + + async init(_context: Context): Promise {} + + async listSessions(): Promise { + return this.sessions.slice(); + } + + async loadMessages(sessionId: string): Promise { + return (this.messagesBySession.get(sessionId) || []).slice(); + } + + async upsertSession(session: RemoteSession): Promise { + this.sessions = [session].concat(this.sessions.filter((item: RemoteSession) => item.id !== session.id)); + } + + async replaceMessages(sessionId: string, messages: ChatMessage[]): Promise { + this.messagesBySession.set(sessionId, messages.slice()); + } + + async deleteSession(sessionId: string): Promise { + this.sessions = this.sessions.filter((item: RemoteSession) => item.id !== sessionId); + this.messagesBySession.delete(sessionId); + this.drafts.delete(sessionId); + } + + async loadDraft(draftId: string): Promise { + return this.drafts.get(draftId) || ''; + } + + async draft(draftId: string): Promise { + return this.loadDraft(draftId); + } + + async saveDraft(draftId: string, text: string): Promise { + this.drafts.set(draftId, text); + } + + async deleteDraft(draftId: string): Promise { + this.drafts.delete(draftId); + } + + async clearDraft(draftId: string): Promise { + await this.deleteDraft(draftId); + } +} + +export class FailingGeneralChatAdapter extends MockGeneralChatAdapter { + async sendMessage( + _sessionId: string, + text: string, + _images: ImageAttachment[] = [], + _history: ChatMessage[] = [], + observer?: GeneralChatStreamObserver + ): Promise { + const userMessage = chatMessage('failed-user', 'user', text, 'pending'); + if (observer) { + await observer.onUserMessage(userMessage); + } + throw new Error('Expected test failure.'); + } +} + +export class PartialFailingGeneralChatAdapter extends MockGeneralChatAdapter { + async sendMessage( + _sessionId: string, + text: string, + _images: ImageAttachment[] = [], + _history: ChatMessage[] = [], + observer?: GeneralChatStreamObserver + ): Promise { + const userMessage = chatMessage('partial-user', 'user', text, 'pending'); + if (observer) { + await observer.onUserMessage(userMessage); + observer.onDelta('partial'); + } + throw new Error('Expected interrupted stream.'); + } +} + +export class FakeGeneralChatTokenProvider implements GeneralChatTokenProvider { + token: string = 'test-token'; + + async accessToken(): Promise { + return this.token; + } +} + +export class FakeGeneralChatHttpTransport implements GeneralChatHttpTransport { + responses: GeneralChatHttpResponse[] = []; + requests: GeneralChatRecordedRequest[] = []; + + async request( + url: string, + method: GeneralChatHttpMethod, + headers: GeneralChatHttpHeaders, + body?: string + ): Promise { + this.requests.push({ + url, + method, + authorization: headers.authorization, + body: body || '' + }); + const response = this.responses.shift(); + if (!response) { + throw new Error('Missing fake general chat HTTP response.'); + } + return response; + } +} + +export class FakeRemoteHeartbeatScheduler implements RemoteHeartbeatScheduler { + callbacks: Map void> = new Map void>(); + intervals: Map = new Map(); + clearedIds: number[] = []; + nextId: number = 1; + + setInterval(callback: () => void, intervalMs: number): number { + const timerId = this.nextId; + this.nextId += 1; + this.callbacks.set(timerId, callback); + this.intervals.set(timerId, intervalMs); + return timerId; + } + + clearInterval(timerId: number): void { + this.clearedIds.push(timerId); + this.callbacks.delete(timerId); + this.intervals.delete(timerId); + } + + tick(timerId: number): void { + const callback = this.callbacks.get(timerId); + if (callback) { + callback(); + } + } +} + +export class FakeGeneralChatDraftScheduler implements GeneralChatDraftScheduler { + callbacks: Map void> = new Map void>(); + delays: Map = new Map(); + clearedIds: number[] = []; + nextId: number = 1; + + setTimeout(callback: () => void, delayMs: number): number { + const timerId = this.nextId; + this.nextId += 1; + this.callbacks.set(timerId, callback); + this.delays.set(timerId, delayMs); + return timerId; + } + + clearTimeout(timerId: number): void { + this.clearedIds.push(timerId); + this.callbacks.delete(timerId); + this.delays.delete(timerId); + } + + tick(timerId: number): void { + const callback = this.callbacks.get(timerId); + if (callback) { + callback(); + this.callbacks.delete(timerId); + this.delays.delete(timerId); + } + } +} + +export class FakeVoiceInputLifecycleService extends VoiceInputService { + startRequests: number = 0; + stopRequests: number = 0; + cancelRequests: number = 0; + shouldFailStart: boolean = false; + shouldFailStop: boolean = false; + fakeListening: boolean = false; + fakeCallbacks?: VoiceInputCallbacks; + + isListening(): boolean { + return this.fakeListening; + } + + async start(_context: Context, callbacks: VoiceInputCallbacks): Promise { + this.startRequests += 1; + if (this.shouldFailStart) { + throw new Error('Expected voice start failure.'); + } + this.fakeCallbacks = callbacks; + this.fakeListening = true; + callbacks.onState?.('listening'); + } + + async stop(): Promise { + this.stopRequests += 1; + if (this.shouldFailStop) { + throw new Error('Expected voice stop failure.'); + } + this.fakeListening = false; + this.fakeCallbacks?.onState?.('idle'); + } + + async cancel(): Promise { + this.cancelRequests += 1; + this.fakeListening = false; + this.fakeCallbacks?.onState?.('idle'); + } + + emitText(text: string, isFinal: boolean): void { + this.fakeCallbacks?.onText(text, isFinal); + } + + emitError(message: string): void { + this.fakeCallbacks?.onError?.(message); + } +} + +export class FakeRemoteFileDownloadScheduler implements RemoteFileDownloadScheduler { + callbacks: Map void> = new Map void>(); + delays: Map = new Map(); + clearedIds: number[] = []; + nextId: number = 1; + + setTimeout(callback: () => void, delayMs: number): number { + const timerId = this.nextId; + this.nextId += 1; + this.callbacks.set(timerId, callback); + this.delays.set(timerId, delayMs); + return timerId; + } + + clearTimeout(timerId: number): void { + this.clearedIds.push(timerId); + this.callbacks.delete(timerId); + this.delays.delete(timerId); + } + + tick(timerId: number): void { + const callback = this.callbacks.get(timerId); + if (callback) { + callback(); + this.callbacks.delete(timerId); + this.delays.delete(timerId); + } + } +} + +export class FakeRemoteFileDownloadClient implements RemoteFileDownloadClient { + infoRequests: string[] = []; + readRequests: string[] = []; + shouldFailRead: boolean = false; + + async getFileInfo(path: string, sessionId?: string): Promise { + this.infoRequests.push(`${path}|${sessionId || ''}`); + return { + name: 'README.md', + size: 4096, + mimeType: 'text/plain' + }; + } + + async readFile( + path: string, + sessionId?: string, + onProgress?: (downloaded: number, total: number) => void + ): Promise { + this.readRequests.push(`${path}|${sessionId || ''}`); + if (onProgress) { + onProgress(2048, 4096); + } + if (this.shouldFailRead) { + throw new Error('Expected download failure.'); + } + return { + name: 'README.md', + contentBase64: 'cmVhZG1l', + mimeType: 'text/plain', + size: 4096 + }; + } +} + +export class RemoteDownloadStatusRecord { + downloadingFilePath: string = ''; + downloadedFilePath: string = ''; + fileDownloadStatus: string = ''; +} + +export class FakeRemoteToolActionClient implements RemoteToolActionClient { + actions: string[] = []; + shouldFail: boolean = false; + + async confirmTool(toolId: string, updatedInput?: Object): Promise { + this.actions.push(`confirm:${toolId}:${JSON.stringify(updatedInput || {})}`); + this.failIfNeeded(); + } + + async rejectTool(toolId: string, reason: string): Promise { + this.actions.push(`reject:${toolId}:${reason}`); + this.failIfNeeded(); + } + + async cancelTool(toolId: string, reason: string): Promise { + this.actions.push(`cancel:${toolId}:${reason}`); + this.failIfNeeded(); + } + + async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + this.actions.push(`answer:${toolId}:${JSON.stringify(answers)}`); + this.failIfNeeded(); + } + + private failIfNeeded(): void { + if (this.shouldFail) { + throw new Error('Expected tool action failure.'); + } + } +} + +export class FakeRemoteModelClient implements RemoteModelClient { + catalog: RemoteModelCatalog = remoteModelCatalog(); + catalogRequests: string[] = []; + modelSelections: string[] = []; + selectedModelId: string = 'model-b'; + shouldFailCatalog: boolean = false; + shouldFailSelect: boolean = false; + + async getModelCatalog(sessionId?: string): Promise { + this.catalogRequests.push(sessionId || ''); + if (this.shouldFailCatalog) { + throw new Error('Expected catalog failure.'); + } + return this.catalog; + } + + async setSessionModel(sessionId: string, modelId: string): Promise { + this.modelSelections.push(`${sessionId}:${modelId}`); + if (this.shouldFailSelect) { + throw new Error('Expected model selection failure.'); + } + return this.selectedModelId; + } +} + +export class FakeRemoteModelPreferenceStore implements RemoteModelPreferenceStore { + savedModelIds: string[] = []; + + async saveLastModelId(modelId: string): Promise { + this.savedModelIds.push(modelId); + } +} + +export class RemoteModelProjectionRecord { + version: number = 0; + selectedModelId: string = ''; + sessionModelId: string = ''; + modelCount: number = 0; +} + +export class FakeRemoteSessionClient implements RemoteSessionClient { + listResults: SessionListResult[] = []; + listRequests: string[] = []; + createRequests: string[] = []; + deleteRequests: string[] = []; + createdSession: SessionSummary = { + sessionId: 'created-session', + title: 'Created Session', + workspacePath: '/workspace', + agentType: 'code' + }; + shouldFailList: boolean = false; + shouldFailCreate: boolean = false; + shouldFailDelete: boolean = false; + + async listSessions(limit: number, offset: number, query: string, agentType: string): Promise { + this.listRequests.push(`${limit}:${offset}:${query}:${agentType}`); + if (this.shouldFailList) { + throw new Error('Expected session list failure.'); + } + const result = this.listResults.shift(); + if (!result) { + throw new Error('Missing fake session list result.'); + } + return result; + } + + async createSession(options: CreateSessionOptions): Promise { + this.createRequests.push(`${options.agentType}:${options.title}:${options.instruction}`); + if (this.shouldFailCreate) { + throw new Error('Expected session create failure.'); + } + return this.createdSession; + } + + async deleteSession(sessionId: string): Promise { + this.deleteRequests.push(sessionId); + if (this.shouldFailDelete) { + throw new Error('Expected session delete failure.'); + } + } +} + +export class RemoteSessionProjectionRecord { + sessions: RemoteSession[] = []; + hasMore: boolean = false; +} + +export class FakeRemoteChatCommandClient implements RemoteChatCommandClient { + messageResults: SessionMessagesResult[] = []; + messageRequests: string[] = []; + sendRequests: string[] = []; + cancelRequests: string[] = []; + renameRequests: string[] = []; + turnId: string = 'turn-1'; + shouldFailMessages: boolean = false; + shouldFailSend: boolean = false; + shouldFailCancel: boolean = false; + shouldFailRename: boolean = false; + + async getSessionMessages(sessionId: string): Promise { + this.messageRequests.push(sessionId); + if (this.shouldFailMessages) { + throw new Error('Expected message load failure.'); + } + const result = this.messageResults.shift(); + if (!result) { + throw new Error('Missing fake session messages result.'); + } + return result; + } + + async sendMessage(sessionId: string, text: string, agentType?: string): Promise { + this.sendRequests.push(`text:${sessionId}:${text}:${agentType || ''}`); + if (this.shouldFailSend) { + throw new Error('Expected send failure.'); + } + return this.turnId; + } + + async sendMessageWithImages( + sessionId: string, + text: string, + agentType: string, + imageContexts: RemoteImageContext[] + ): Promise { + this.sendRequests.push(`images:${sessionId}:${text}:${agentType}:${imageContexts.length}`); + if (this.shouldFailSend) { + throw new Error('Expected send failure.'); + } + return this.turnId; + } + + async cancelTask(sessionId: string, turnId?: string): Promise { + this.cancelRequests.push(`${sessionId}:${turnId || ''}`); + if (this.shouldFailCancel) { + throw new Error('Expected cancel failure.'); + } + } + + async renameSession(sessionId: string, title: string): Promise { + this.renameRequests.push(`${sessionId}:${title}`); + if (this.shouldFailRename) { + throw new Error('Expected rename failure.'); + } + } +} + +export class RemoteChatMessagesProjectionRecord { + messages: ChatMessage[] = []; + hasMoreMessages: boolean = true; + pollVersion: number = -1; + knownMessageCount: number = -1; +} + +export class FakeGeneralChatCommandClient implements GeneralChatCommandClient { + currentSessions: RemoteSession[] = [remoteSession('chat-1', 'Chat 1')]; + messagesBySession: Map = new Map(); + initRequests: number = 0; + createRequests: string[] = []; + deleteRequests: string[] = []; + archiveRequests: string[] = []; + renameRequests: string[] = []; + removeFailedRequests: string[] = []; + messageRequests: string[] = []; + sendRequests: string[] = []; + cancelCount: number = 0; + recordedAssistantMessages: string[] = []; + shouldFail: boolean = false; + shouldFailSend: boolean = false; + shouldFailRecordAssistant: boolean = false; + + async init(_context: Context): Promise { + this.initRequests += 1; + this.failIfNeeded(); + } + + sessions(): RemoteSession[] { + return this.currentSessions.slice(); + } + + async createSession(firstMessage: string): Promise { + this.createRequests.push(firstMessage); + this.failIfNeeded(); + const session = remoteSession('chat-created', firstMessage); + session.agentType = 'chat'; + this.currentSessions = [session].concat(this.currentSessions); + return session; + } + + async messages(sessionId: string): Promise { + this.messageRequests.push(sessionId); + this.failIfNeeded(); + return (this.messagesBySession.get(sessionId) || []).slice(); + } + + async sendMessage( + sessionId: string, + text: string, + images: ImageAttachment[], + observer?: GeneralChatStreamObserver + ): Promise { + this.sendRequests.push(`${sessionId}:${text}:${images.length}`); + if (this.shouldFailSend) { + throw new Error('Expected general chat send failure.'); + } + const userMessage = chatMessage('sent-user', 'user', text, 'completed'); + const assistantMessage = chatMessage('sent-assistant', 'assistant', `Echo: ${text}`, 'completed'); + if (observer) { + await observer.onUserMessage(userMessage); + observer.onDelta('Echo: '); + observer.onDelta(text); + } + return { + userMessage, + assistantMessage, + streamChunks: ['Echo: ', text], + tools: [] + }; + } + + cancelCurrentRequest(): void { + this.cancelCount += 1; + } + + async recordAssistantMessage(sessionId: string, message: ChatMessage): Promise { + if (this.shouldFailRecordAssistant) { + throw new Error('Expected general chat record failure.'); + } + this.recordedAssistantMessages.push(`${sessionId}:${message.id}`); + } + + async renameSession(sessionId: string, title: string): Promise { + this.renameRequests.push(`${sessionId}:${title}`); + this.failIfNeeded(); + this.currentSessions = this.currentSessions.map((session: RemoteSession) => { + if (session.id !== sessionId) { + return session; + } + return { + id: session.id, + title, + agentType: session.agentType, + status: session.status, + updatedAt: session.updatedAt, + createdAt: session.createdAt, + messageCount: session.messageCount, + workspacePath: session.workspacePath, + workspaceName: session.workspaceName + }; + }); + } + + async archiveSession(sessionId: string, archived: boolean): Promise { + this.archiveRequests.push(`${sessionId}:${archived ? '1' : '0'}`); + this.failIfNeeded(); + } + + async pinSession(sessionId: string, pinned: boolean): Promise { + this.failIfNeeded(); + this.currentSessions = this.currentSessions.map((session: RemoteSession) => { + const updated: RemoteSession = { + id: session.id, + title: session.title, + agentType: session.agentType, + status: session.status, + updatedAt: session.updatedAt, + createdAt: session.createdAt, + messageCount: session.messageCount, + workspacePath: session.workspacePath, + workspaceName: session.workspaceName, + pinned: session.id === sessionId ? pinned : false, + titleGenerated: session.titleGenerated + }; + return updated; + }); + } + + async deleteSession(sessionId: string): Promise { + this.deleteRequests.push(sessionId); + this.failIfNeeded(); + this.currentSessions = this.currentSessions.filter((session: RemoteSession) => session.id !== sessionId); + } + + async removeFailedUserMessage(sessionId: string, text: string): Promise { + this.removeFailedRequests.push(`${sessionId}:${text}`); + this.failIfNeeded(); + } + + private failIfNeeded(): void { + if (this.shouldFail) { + throw new Error('Expected general chat command failure.'); + } + } +} + +export class FakeGeneralChatConfigStore extends GeneralChatConfigStore { + initRequests: number = 0; + snapshotResult: GeneralChatConfigSnapshot = { + apiUrl: 'https://api.example.com', + modelName: 'model-a', + hasApiKey: true + }; + shouldFailInit: boolean = false; + + async init(_context: Context): Promise { + this.initRequests += 1; + if (this.shouldFailInit) { + throw new Error('Expected config init failure.'); + } + return this.snapshotResult; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index 6878a2e604..38c3ed8450 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -1,4155 +1,11 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; -import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; -import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; -import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; -import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; -import { Encoding } from '../main/ets/services/Encoding'; -import { - GeneralChatDraftStore, - GeneralChatLocalStore, - GeneralChatSendResult, - GeneralChatStreamCallbacks, - GeneralChatStreamObserver -} from '../main/ets/services/general-chat/GeneralChatPort'; -import { GeneralChatApiClient } from '../main/ets/services/general-chat/GeneralChatApiClient'; -import { GeneralChatBootstrapController } from '../main/ets/services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ets/services/general-chat/GeneralChatCommandController'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigValidator -} from '../main/ets/services/general-chat/GeneralChatConfigStore'; -import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; -import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; -import { - GeneralChatHttpHeaders, - GeneralChatHttpMethod, - GeneralChatHttpResponse, - GeneralChatHttpTransport, - GeneralChatTokenProvider -} from '../main/ets/services/general-chat/GeneralChatHttpTransport'; -import { GeneralChatRepository } from '../main/ets/services/general-chat/GeneralChatRepository'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../main/ets/services/general-chat/GeneralChatServiceState'; -import { GeneralChatStreamCoordinator } from '../main/ets/services/general-chat/GeneralChatStreamCoordinator'; -import { GeneralChatStreamLifecycleController } from '../main/ets/services/general-chat/GeneralChatStreamLifecycleController'; -import { - ModelProviderGeneralChatAdapter, - ModelProviderMessage, - ModelProviderProtocol -} from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; -import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; -import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; -import { MarkdownParser } from '../main/ets/services/MarkdownParser'; -import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; -import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; -import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; -import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; -import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; -import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; -import { - RemoteFileDownloadClient, - RemoteFileDownloadController, - RemoteFileDownloadScheduler -} from '../main/ets/services/RemoteFileDownloadController'; -import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; -import { - RemoteModelClient, - RemoteModelController, - RemoteModelPreferenceStore -} from '../main/ets/services/RemoteModelController'; -import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; -import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; -import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; -import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; -import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; -import { RemoteUiState } from '../main/ets/services/RemoteUiState'; -import { - VoiceInputLifecycleCallbacks, - VoiceInputLifecycleController, - VoiceInputRouteSnapshot -} from '../main/ets/services/VoiceInputLifecycleController'; -import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; -import { AppShellState } from '../main/ets/pages/state/AppShellState'; -import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; -import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; -import { - GENERAL_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../main/ets/pages/components/ChatComposerCapabilities'; -import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; -import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; -import { - AppNavigationBackAction, - AppNavigationPathSpec, - AppRoute, - AppRouteContract -} from '../main/ets/pages/navigation/AppRouteContract'; -import { TimeFormat } from '../main/ets/services/TimeFormat'; -import { X25519 } from '../main/ets/services/X25519'; -import { - ChatMessage, - FileInfo, - ImageAttachment, - PollSessionResult, - RecentWorkspaceEntry, - AssistantEntry, - CreateSessionOptions, - RemoteImageContext, - RemoteQuestionAnswerPayload, - RemoteModelCatalog, - ReadFileResult, - RemoteSession, - SelectedImageAttachment, - SessionListResult, - SessionMessagesResult, - SessionSummary -} from '../main/ets/model/RemoteModels'; - -interface ToolInputFixture { - safe: boolean; -} - -interface CryptoImageFixture { - id: string; - data_url: string; - mime_type: string; -} - -interface CryptoCommandFixture { - cmd: string; - session_id: string; - content: string; - agent_type: string; - image_contexts: CryptoImageFixture[]; -} - -interface CryptoSimpleCommandFixture { - cmd: string; -} - -interface GeneralChatRecordedRequest { - url: string; - method: GeneralChatHttpMethod; - authorization: string; - body: string; -} - -class FakeRemoteCryptoCipher implements RemoteCryptoCipher { - async encrypt(plainBytes: Uint8Array, key: Uint8Array, nonce: Uint8Array): Promise { - const out = new Uint8Array(plainBytes.length + 16); - for (let index = 0; index < plainBytes.length; index++) { - out[index] = plainBytes[index] ^ key[index % key.length] ^ nonce[index % nonce.length]; - } - for (let index = 0; index < 16; index++) { - out[plainBytes.length + index] = key[index] ^ nonce[index % nonce.length] ^ 165; - } - return out; - } - - async decrypt(encryptedBytes: Uint8Array, key: Uint8Array, nonce: Uint8Array): Promise { - if (encryptedBytes.length < 16) { - throw new Error('Encrypted payload is too short.'); - } - const plainLength = encryptedBytes.length - 16; - const out = new Uint8Array(plainLength); - for (let index = 0; index < plainLength; index++) { - out[index] = encryptedBytes[index] ^ key[index % key.length] ^ nonce[index % nonce.length]; - } - return out; - } -} - -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let index = 0; index < bytes.length; index++) { - bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); - } - return bytes; -} - -function bytesToHex(bytes: Uint8Array): string { - const alphabet = '0123456789abcdef'; - let text = ''; - for (let index = 0; index < bytes.length; index++) { - const value = bytes[index]; - text += alphabet.charAt((value >> 4) & 15) + alphabet.charAt(value & 15); - } - return text; -} - -function fixedRandomBytes(size: number): Uint8Array { - const bytes = new Uint8Array(size); - for (let index = 0; index < size; index++) { - bytes[index] = (index * 17 + 29) & 255; - } - return bytes; -} - -function expectCommandJson(command: Object, expected: string): void { - expect(JSON.stringify(command)).assertEqual(expected); -} - -function connectedPeers(): RemoteCrypto[] { - const cipher = new FakeRemoteCryptoCipher(); - const alicePrivate = hexToBytes('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'); - const bobPrivate = hexToBytes('5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb'); - const alice = new RemoteCrypto({ - cipher, - keyPair: { - privateKey: alicePrivate, - publicKey: X25519.scalarMultBase(alicePrivate) - }, - randomBytes: fixedRandomBytes - }); - const bob = new RemoteCrypto({ - cipher, - keyPair: { - privateKey: bobPrivate, - publicKey: X25519.scalarMultBase(bobPrivate) - }, - randomBytes: fixedRandomBytes - }); - alice.deriveSharedKey(bob.getPublicKeyBase64()); - bob.deriveSharedKey(alice.getPublicKeyBase64()); - return [alice, bob]; -} - -function chatMessage(id: string, role: string, text: string, status: string = 'completed'): ChatMessage { - return { - id, - role, - text, - status, - detail: '' - }; -} - -function remoteSession(id: string, title: string, status: string = 'active'): RemoteSession { - return { - id, - title, - agentType: 'code', - status, - updatedAt: '2026-07-21T00:00:00.000Z', - createdAt: '2026-07-21T00:00:00.000Z', - messageCount: 1 - }; -} - -function selectedImage(id: string): SelectedImageAttachment { - return { - id, - uri: `file:///tmp/${id}.png`, - name: `${id}.png`, - data_url: 'data:image/png;base64,AA==', - mime_type: 'image/png', - size: 2, - original_size: 2, - compressed: false - }; -} - -function remoteModelCatalog(sessionModelId: string = ''): RemoteModelCatalog { - return { - version: 2, - models: [{ - id: 'model-a', - name: 'Model A', - provider: 'local', - base_url: 'https://model.example.com', - model_name: 'model-a', - enabled: true, - capabilities: ['code'] - }, { - id: 'model-b', - name: 'Model B', - provider: 'local', - base_url: 'https://model.example.com', - model_name: 'model-b', - enabled: true, - capabilities: ['code'] - }], - default_models: { primary: 'model-a' }, - session_model_id: sessionModelId - }; -} - -function activeChatMessage(turnId: string, text: string, status: string = 'active', renderVersion: number = 0): ChatMessage { - const message = chatMessage(`active-${turnId}`, 'assistant', text, status); - message.turnId = turnId; - message.renderVersion = renderVersion; - return message; -} - -function chatMessageWithImage(id: string, role: string, text: string, imageName: string, imageDataUrl: string): ChatMessage { - return { - id, - role, - text, - status: 'completed', - detail: '', - images: [imageAttachment(imageName, imageDataUrl)] - }; -} - -function imageAttachment(name: string, dataUrl: string): ImageAttachment { - return { - name, - data_url: dataUrl - }; -} - -function pollResult(options: PollSessionResult): PollSessionResult { - return options; -} - -function delay(ms: number): Promise { - return new Promise((resolve: () => void) => { - setTimeout(() => { - resolve(); - }, ms); - }); -} - -class FakePollSessionManager extends RemoteSessionManager { - results: PollSessionResult[] = []; - - async pollSession( - _sessionId: string, - _sinceVersion: number, - _knownMessageCount: number, - _knownModelCatalogVersion: number - ): Promise { - const next = this.results.shift(); - if (!next) { - throw new Error('Missing fake poll result.'); - } - return next; - } -} - -class FakeGeneralChatLocalStore implements GeneralChatLocalStore, GeneralChatDraftStore { - sessions: RemoteSession[] = []; - messagesBySession: Map = new Map(); - drafts: Map = new Map(); - - async init(_context: Context): Promise {} - - async listSessions(): Promise { - return this.sessions.slice(); - } - - async loadMessages(sessionId: string): Promise { - return (this.messagesBySession.get(sessionId) || []).slice(); - } - - async upsertSession(session: RemoteSession): Promise { - this.sessions = [session].concat(this.sessions.filter((item: RemoteSession) => item.id !== session.id)); - } - - async replaceMessages(sessionId: string, messages: ChatMessage[]): Promise { - this.messagesBySession.set(sessionId, messages.slice()); - } - - async deleteSession(sessionId: string): Promise { - this.sessions = this.sessions.filter((item: RemoteSession) => item.id !== sessionId); - this.messagesBySession.delete(sessionId); - this.drafts.delete(sessionId); - } - - async loadDraft(draftId: string): Promise { - return this.drafts.get(draftId) || ''; - } - - async draft(draftId: string): Promise { - return this.loadDraft(draftId); - } - - async saveDraft(draftId: string, text: string): Promise { - this.drafts.set(draftId, text); - } - - async deleteDraft(draftId: string): Promise { - this.drafts.delete(draftId); - } - - async clearDraft(draftId: string): Promise { - await this.deleteDraft(draftId); - } -} - -class FailingGeneralChatAdapter extends MockGeneralChatAdapter { - async sendMessage( - _sessionId: string, - text: string, - _images: ImageAttachment[] = [], - _history: ChatMessage[] = [], - observer?: GeneralChatStreamObserver - ): Promise { - const userMessage = chatMessage('failed-user', 'user', text, 'pending'); - if (observer) { - await observer.onUserMessage(userMessage); - } - throw new Error('Expected test failure.'); - } -} - -class PartialFailingGeneralChatAdapter extends MockGeneralChatAdapter { - async sendMessage( - _sessionId: string, - text: string, - _images: ImageAttachment[] = [], - _history: ChatMessage[] = [], - observer?: GeneralChatStreamObserver - ): Promise { - const userMessage = chatMessage('partial-user', 'user', text, 'pending'); - if (observer) { - await observer.onUserMessage(userMessage); - observer.onDelta('partial'); - } - throw new Error('Expected interrupted stream.'); - } -} - -class FakeGeneralChatTokenProvider implements GeneralChatTokenProvider { - token: string = 'test-token'; - - async accessToken(): Promise { - return this.token; - } -} - -class FakeGeneralChatHttpTransport implements GeneralChatHttpTransport { - responses: GeneralChatHttpResponse[] = []; - requests: GeneralChatRecordedRequest[] = []; - - async request( - url: string, - method: GeneralChatHttpMethod, - headers: GeneralChatHttpHeaders, - body?: string - ): Promise { - this.requests.push({ - url, - method, - authorization: headers.authorization, - body: body || '' - }); - const response = this.responses.shift(); - if (!response) { - throw new Error('Missing fake general chat HTTP response.'); - } - return response; - } -} - -class FakeRemoteHeartbeatScheduler implements RemoteHeartbeatScheduler { - callbacks: Map void> = new Map void>(); - intervals: Map = new Map(); - clearedIds: number[] = []; - nextId: number = 1; - - setInterval(callback: () => void, intervalMs: number): number { - const timerId = this.nextId; - this.nextId += 1; - this.callbacks.set(timerId, callback); - this.intervals.set(timerId, intervalMs); - return timerId; - } - - clearInterval(timerId: number): void { - this.clearedIds.push(timerId); - this.callbacks.delete(timerId); - this.intervals.delete(timerId); - } - - tick(timerId: number): void { - const callback = this.callbacks.get(timerId); - if (callback) { - callback(); - } - } -} - -class FakeGeneralChatDraftScheduler implements GeneralChatDraftScheduler { - callbacks: Map void> = new Map void>(); - delays: Map = new Map(); - clearedIds: number[] = []; - nextId: number = 1; - - setTimeout(callback: () => void, delayMs: number): number { - const timerId = this.nextId; - this.nextId += 1; - this.callbacks.set(timerId, callback); - this.delays.set(timerId, delayMs); - return timerId; - } - - clearTimeout(timerId: number): void { - this.clearedIds.push(timerId); - this.callbacks.delete(timerId); - this.delays.delete(timerId); - } - - tick(timerId: number): void { - const callback = this.callbacks.get(timerId); - if (callback) { - callback(); - this.callbacks.delete(timerId); - this.delays.delete(timerId); - } - } -} - -class FakeVoiceInputLifecycleService extends VoiceInputService { - startRequests: number = 0; - stopRequests: number = 0; - cancelRequests: number = 0; - shouldFailStart: boolean = false; - shouldFailStop: boolean = false; - fakeListening: boolean = false; - fakeCallbacks?: VoiceInputCallbacks; - - isListening(): boolean { - return this.fakeListening; - } - - async start(_context: Context, callbacks: VoiceInputCallbacks): Promise { - this.startRequests += 1; - if (this.shouldFailStart) { - throw new Error('Expected voice start failure.'); - } - this.fakeCallbacks = callbacks; - this.fakeListening = true; - callbacks.onState?.('listening'); - } - - async stop(): Promise { - this.stopRequests += 1; - if (this.shouldFailStop) { - throw new Error('Expected voice stop failure.'); - } - this.fakeListening = false; - this.fakeCallbacks?.onState?.('idle'); - } - - async cancel(): Promise { - this.cancelRequests += 1; - this.fakeListening = false; - this.fakeCallbacks?.onState?.('idle'); - } - - emitText(text: string, isFinal: boolean): void { - this.fakeCallbacks?.onText(text, isFinal); - } - - emitError(message: string): void { - this.fakeCallbacks?.onError?.(message); - } -} - -class FakeRemoteFileDownloadScheduler implements RemoteFileDownloadScheduler { - callbacks: Map void> = new Map void>(); - delays: Map = new Map(); - clearedIds: number[] = []; - nextId: number = 1; - - setTimeout(callback: () => void, delayMs: number): number { - const timerId = this.nextId; - this.nextId += 1; - this.callbacks.set(timerId, callback); - this.delays.set(timerId, delayMs); - return timerId; - } - - clearTimeout(timerId: number): void { - this.clearedIds.push(timerId); - this.callbacks.delete(timerId); - this.delays.delete(timerId); - } - - tick(timerId: number): void { - const callback = this.callbacks.get(timerId); - if (callback) { - callback(); - this.callbacks.delete(timerId); - this.delays.delete(timerId); - } - } -} - -class FakeRemoteFileDownloadClient implements RemoteFileDownloadClient { - infoRequests: string[] = []; - readRequests: string[] = []; - shouldFailRead: boolean = false; - - async getFileInfo(path: string, sessionId?: string): Promise { - this.infoRequests.push(`${path}|${sessionId || ''}`); - return { - name: 'README.md', - size: 4096, - mimeType: 'text/plain' - }; - } - - async readFile( - path: string, - sessionId?: string, - onProgress?: (downloaded: number, total: number) => void - ): Promise { - this.readRequests.push(`${path}|${sessionId || ''}`); - if (onProgress) { - onProgress(2048, 4096); - } - if (this.shouldFailRead) { - throw new Error('Expected download failure.'); - } - return { - name: 'README.md', - contentBase64: 'cmVhZG1l', - mimeType: 'text/plain', - size: 4096 - }; - } -} - -class RemoteDownloadStatusRecord { - downloadingFilePath: string = ''; - downloadedFilePath: string = ''; - fileDownloadStatus: string = ''; -} - -class FakeRemoteToolActionClient implements RemoteToolActionClient { - actions: string[] = []; - shouldFail: boolean = false; - - async confirmTool(toolId: string, updatedInput?: Object): Promise { - this.actions.push(`confirm:${toolId}:${JSON.stringify(updatedInput || {})}`); - this.failIfNeeded(); - } - - async rejectTool(toolId: string, reason: string): Promise { - this.actions.push(`reject:${toolId}:${reason}`); - this.failIfNeeded(); - } - - async cancelTool(toolId: string, reason: string): Promise { - this.actions.push(`cancel:${toolId}:${reason}`); - this.failIfNeeded(); - } - - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - this.actions.push(`answer:${toolId}:${JSON.stringify(answers)}`); - this.failIfNeeded(); - } - - private failIfNeeded(): void { - if (this.shouldFail) { - throw new Error('Expected tool action failure.'); - } - } -} - -class FakeRemoteModelClient implements RemoteModelClient { - catalog: RemoteModelCatalog = remoteModelCatalog(); - catalogRequests: string[] = []; - modelSelections: string[] = []; - selectedModelId: string = 'model-b'; - shouldFailCatalog: boolean = false; - shouldFailSelect: boolean = false; - - async getModelCatalog(sessionId?: string): Promise { - this.catalogRequests.push(sessionId || ''); - if (this.shouldFailCatalog) { - throw new Error('Expected catalog failure.'); - } - return this.catalog; - } - - async setSessionModel(sessionId: string, modelId: string): Promise { - this.modelSelections.push(`${sessionId}:${modelId}`); - if (this.shouldFailSelect) { - throw new Error('Expected model selection failure.'); - } - return this.selectedModelId; - } -} - -class FakeRemoteModelPreferenceStore implements RemoteModelPreferenceStore { - savedModelIds: string[] = []; - - async saveLastModelId(modelId: string): Promise { - this.savedModelIds.push(modelId); - } -} - -class RemoteModelProjectionRecord { - version: number = 0; - selectedModelId: string = ''; - sessionModelId: string = ''; - modelCount: number = 0; -} - -class FakeRemoteSessionClient implements RemoteSessionClient { - listResults: SessionListResult[] = []; - listRequests: string[] = []; - createRequests: string[] = []; - deleteRequests: string[] = []; - createdSession: SessionSummary = { - sessionId: 'created-session', - title: 'Created Session', - workspacePath: '/workspace', - agentType: 'code' - }; - shouldFailList: boolean = false; - shouldFailCreate: boolean = false; - shouldFailDelete: boolean = false; - - async listSessions(limit: number, offset: number, query: string, agentType: string): Promise { - this.listRequests.push(`${limit}:${offset}:${query}:${agentType}`); - if (this.shouldFailList) { - throw new Error('Expected session list failure.'); - } - const result = this.listResults.shift(); - if (!result) { - throw new Error('Missing fake session list result.'); - } - return result; - } - - async createSession(options: CreateSessionOptions): Promise { - this.createRequests.push(`${options.agentType}:${options.title}:${options.instruction}`); - if (this.shouldFailCreate) { - throw new Error('Expected session create failure.'); - } - return this.createdSession; - } - - async deleteSession(sessionId: string): Promise { - this.deleteRequests.push(sessionId); - if (this.shouldFailDelete) { - throw new Error('Expected session delete failure.'); - } - } -} - -class RemoteSessionProjectionRecord { - sessions: RemoteSession[] = []; - hasMore: boolean = false; -} - -class FakeRemoteChatCommandClient implements RemoteChatCommandClient { - messageResults: SessionMessagesResult[] = []; - messageRequests: string[] = []; - sendRequests: string[] = []; - cancelRequests: string[] = []; - renameRequests: string[] = []; - turnId: string = 'turn-1'; - shouldFailMessages: boolean = false; - shouldFailSend: boolean = false; - shouldFailCancel: boolean = false; - shouldFailRename: boolean = false; - - async getSessionMessages(sessionId: string): Promise { - this.messageRequests.push(sessionId); - if (this.shouldFailMessages) { - throw new Error('Expected message load failure.'); - } - const result = this.messageResults.shift(); - if (!result) { - throw new Error('Missing fake session messages result.'); - } - return result; - } - - async sendMessage(sessionId: string, text: string, agentType?: string): Promise { - this.sendRequests.push(`text:${sessionId}:${text}:${agentType || ''}`); - if (this.shouldFailSend) { - throw new Error('Expected send failure.'); - } - return this.turnId; - } - - async sendMessageWithImages( - sessionId: string, - text: string, - agentType: string, - imageContexts: RemoteImageContext[] - ): Promise { - this.sendRequests.push(`images:${sessionId}:${text}:${agentType}:${imageContexts.length}`); - if (this.shouldFailSend) { - throw new Error('Expected send failure.'); - } - return this.turnId; - } - - async cancelTask(sessionId: string, turnId?: string): Promise { - this.cancelRequests.push(`${sessionId}:${turnId || ''}`); - if (this.shouldFailCancel) { - throw new Error('Expected cancel failure.'); - } - } - - async renameSession(sessionId: string, title: string): Promise { - this.renameRequests.push(`${sessionId}:${title}`); - if (this.shouldFailRename) { - throw new Error('Expected rename failure.'); - } - } -} - -class RemoteChatMessagesProjectionRecord { - messages: ChatMessage[] = []; - hasMoreMessages: boolean = true; - pollVersion: number = -1; - knownMessageCount: number = -1; -} - -class FakeGeneralChatCommandClient implements GeneralChatCommandClient { - currentSessions: RemoteSession[] = [remoteSession('chat-1', 'Chat 1')]; - messagesBySession: Map = new Map(); - initRequests: number = 0; - createRequests: string[] = []; - deleteRequests: string[] = []; - archiveRequests: string[] = []; - renameRequests: string[] = []; - removeFailedRequests: string[] = []; - messageRequests: string[] = []; - sendRequests: string[] = []; - cancelCount: number = 0; - recordedAssistantMessages: string[] = []; - shouldFail: boolean = false; - shouldFailSend: boolean = false; - shouldFailRecordAssistant: boolean = false; - - async init(_context: Context): Promise { - this.initRequests += 1; - this.failIfNeeded(); - } - - sessions(): RemoteSession[] { - return this.currentSessions.slice(); - } - - async createSession(firstMessage: string): Promise { - this.createRequests.push(firstMessage); - this.failIfNeeded(); - const session = remoteSession('chat-created', firstMessage); - session.agentType = 'chat'; - this.currentSessions = [session].concat(this.currentSessions); - return session; - } - - async messages(sessionId: string): Promise { - this.messageRequests.push(sessionId); - this.failIfNeeded(); - return (this.messagesBySession.get(sessionId) || []).slice(); - } - - async sendMessage( - sessionId: string, - text: string, - images: ImageAttachment[], - observer?: GeneralChatStreamObserver - ): Promise { - this.sendRequests.push(`${sessionId}:${text}:${images.length}`); - if (this.shouldFailSend) { - throw new Error('Expected general chat send failure.'); - } - const userMessage = chatMessage('sent-user', 'user', text, 'completed'); - const assistantMessage = chatMessage('sent-assistant', 'assistant', `Echo: ${text}`, 'completed'); - if (observer) { - await observer.onUserMessage(userMessage); - observer.onDelta('Echo: '); - observer.onDelta(text); - } - return { - userMessage, - assistantMessage, - streamChunks: ['Echo: ', text], - tools: [] - }; - } - - cancelCurrentRequest(): void { - this.cancelCount += 1; - } - - async recordAssistantMessage(sessionId: string, message: ChatMessage): Promise { - if (this.shouldFailRecordAssistant) { - throw new Error('Expected general chat record failure.'); - } - this.recordedAssistantMessages.push(`${sessionId}:${message.id}`); - } - - async renameSession(sessionId: string, title: string): Promise { - this.renameRequests.push(`${sessionId}:${title}`); - this.failIfNeeded(); - this.currentSessions = this.currentSessions.map((session: RemoteSession) => { - if (session.id !== sessionId) { - return session; - } - return { - id: session.id, - title, - agentType: session.agentType, - status: session.status, - updatedAt: session.updatedAt, - createdAt: session.createdAt, - messageCount: session.messageCount, - workspacePath: session.workspacePath, - workspaceName: session.workspaceName - }; - }); - } - - async archiveSession(sessionId: string, archived: boolean): Promise { - this.archiveRequests.push(`${sessionId}:${archived ? '1' : '0'}`); - this.failIfNeeded(); - } - - async deleteSession(sessionId: string): Promise { - this.deleteRequests.push(sessionId); - this.failIfNeeded(); - this.currentSessions = this.currentSessions.filter((session: RemoteSession) => session.id !== sessionId); - } - - async removeFailedUserMessage(sessionId: string, text: string): Promise { - this.removeFailedRequests.push(`${sessionId}:${text}`); - this.failIfNeeded(); - } - - private failIfNeeded(): void { - if (this.shouldFail) { - throw new Error('Expected general chat command failure.'); - } - } -} - -class FakeGeneralChatConfigStore extends GeneralChatConfigStore { - initRequests: number = 0; - snapshotResult: GeneralChatConfigSnapshot = { - apiUrl: 'https://api.example.com', - modelName: 'model-a', - hasApiKey: true - }; - shouldFailInit: boolean = false; - - async init(_context: Context): Promise { - this.initRequests += 1; - if (this.shouldFailInit) { - throw new Error('Expected config init failure.'); - } - return this.snapshotResult; - } -} +import transportAndGeneralChatUnitTest from './TransportAndGeneralChatUnit.test'; +import conversationStateUnitTest from './ConversationStateUnit.test'; +import lifecycleUnitTest from './LifecycleUnit.test'; +import remoteControllersUnitTest from './RemoteControllersUnit.test'; export default function localUnitTest() { - describe('RemoteDescriptorParser', () => { - it('parses hash route URLs', 0, () => { - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/r/mobile#/pair?room=room-a&pk=public-key'); - expect(descriptor.roomId).assertEqual('room-a'); - expect(descriptor.publicKey).assertEqual('public-key'); - expect(descriptor.relayUrl).assertEqual('https://relay.example.com'); - }); - - it('normalizes relay websocket URLs', 0, () => { - const descriptor = RemoteDescriptorParser.parse('https://app.example.com/#/pair?room=room-b&pk=key-b&relay=wss%3A%2F%2Frelay.example.com%2Fws'); - expect(descriptor.relayUrl).assertEqual('https://relay.example.com'); - }); - - it('parses account pairing metadata', 0, () => { - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-account&pk=key-account&auth=account&user=alice'); - expect(descriptor.accountAuth).assertTrue(); - expect(descriptor.accountUsername).assertEqual('alice'); - }); - - it('parses raw query strings', 0, () => { - const descriptor = RemoteDescriptorParser.parse('room=room-c&pk=key%2Bc%3D&relay=http%3A%2F%2F127.0.0.1%3A30333'); - expect(descriptor.roomId).assertEqual('room-c'); - expect(descriptor.publicKey).assertEqual('key+c='); - expect(descriptor.relayUrl).assertEqual('http://127.0.0.1:30333'); - }); - - it('rejects URLs missing room', 0, () => { - let didThrow = false; - try { - RemoteDescriptorParser.parse('https://relay.example.com/#/pair?pk=key-only'); - } catch (_err) { - didThrow = true; - } - expect(didThrow).assertEqual(true); - }); - - it('rejects URLs missing pk', 0, () => { - let didThrow = false; - try { - RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-only'); - } catch (_err) { - didThrow = true; - } - expect(didThrow).assertEqual(true); - }); - }); - - describe('RemotePairingPolicy', () => { - it('prefills account username from QR only over the default install id', 0, () => { - const policy = new RemotePairingPolicy(); - const projection = policy.projection('https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice'); - - expect(projection.requiresAccountAuth).assertTrue(); - expect(projection.accountUsername).assertEqual('alice'); - expect(policy.userIdAfterProjection('harmony-device', 'harmony-device', projection)).assertEqual('alice'); - expect(policy.userIdAfterProjection('manual-user', 'harmony-device', projection)).assertEqual('manual-user'); - }); - - it('blocks passwordless account reconnects and builds encrypted pairing identity', 0, () => { - const policy = new RemotePairingPolicy(); - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice'); - - expect(policy.shouldAutoReconnect({ - autoReconnectAttempted: false, - remoteUrl: 'https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice', - userId: 'alice', - requiresAccountAuth: true - })).assertFalse(); - - const identity = policy.identityForConnect(descriptor, '', 'harmony-device', 'secret-password', false); - expect(identity.userId).assertEqual('alice'); - expect(identity.password).assertEqual('secret-password'); - }); - - it('keeps ordinary pairing passwordless and auto reconnectable', 0, () => { - const policy = new RemotePairingPolicy(); - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-a&pk=key-a'); - const identity = policy.identityForConnect(descriptor, '', 'harmony-device', '', true); - - expect(identity.userId).assertEqual('harmony-device'); - expect(identity.password || '').assertEqual(''); - expect(policy.shouldAutoReconnect({ - autoReconnectAttempted: false, - remoteUrl: 'https://relay.example.com/#/pair?room=room-a&pk=key-a', - userId: 'harmony-device', - requiresAccountAuth: false - })).assertTrue(); - }); - }); - - describe('X25519', () => { - const alicePrivate = '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'; - const alicePublic = '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a'; - const bobPrivate = '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb'; - const bobPublic = 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f'; - const sharedSecret = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742'; - const mobileWebPrivate = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; - const mobileWebPublic = '8f40c5adb68f25624ae5b214ea767a6ec94d829d3d7b5e1ad1ba6f3e2138285f'; - const desktopPrivate = 'f0e1d2c3b4a5968778695a4b3c2d1e0f00112233445566778899aabbccddeeff'; - const desktopPublic = 'ed99ba8df1a6a8c6396beafc3a42b8212fde389a7663217e170e82b68377e858'; - const mobileWebSharedSecret = '251b4e394babbef8dc30269e5a065946c08d89d6df010376549baa469c552f4b'; - - it('matches RFC 7748 public key vectors', 0, () => { - expect(bytesToHex(X25519.scalarMultBase(hexToBytes(alicePrivate)))).assertEqual(alicePublic); - expect(bytesToHex(X25519.scalarMultBase(hexToBytes(bobPrivate)))).assertEqual(bobPublic); - }); - - it('matches RFC 7748 shared secret vector from both peers', 0, () => { - expect(bytesToHex(X25519.scalarMult(hexToBytes(alicePrivate), hexToBytes(bobPublic)))).assertEqual(sharedSecret); - expect(bytesToHex(X25519.scalarMult(hexToBytes(bobPrivate), hexToBytes(alicePublic)))).assertEqual(sharedSecret); - }); - - it('matches the mobile-web noble-curves shared secret shape', 0, () => { - expect(bytesToHex(X25519.scalarMultBase(hexToBytes(mobileWebPrivate)))).assertEqual(mobileWebPublic); - expect(bytesToHex(X25519.scalarMultBase(hexToBytes(desktopPrivate)))).assertEqual(desktopPublic); - expect(bytesToHex(X25519.scalarMult(hexToBytes(mobileWebPrivate), hexToBytes(desktopPublic)))) - .assertEqual(mobileWebSharedSecret); - expect(bytesToHex(X25519.scalarMult(hexToBytes(desktopPrivate), hexToBytes(mobileWebPublic)))) - .assertEqual(mobileWebSharedSecret); - }); - }); - - describe('RemoteCrypto', () => { - it('encrypts and decrypts JSON between derived peers', 0, async () => { - const peers = connectedPeers(); - const alice = peers[0]; - const bob = peers[1]; - const command: CryptoCommandFixture = { - cmd: 'send_message', - session_id: 'session-1', - content: 'hello from mobile', - agent_type: 'code', - image_contexts: [{ - id: 'image-1', - data_url: 'data:image/png;base64,aGVsbG8=', - mime_type: 'image/png' - }] - }; - - const encrypted = await alice.encryptJson(command); - const decrypted = await bob.decryptJson(encrypted); - - expect(decrypted.cmd).assertEqual(command.cmd); - expect(decrypted.session_id).assertEqual(command.session_id); - expect(decrypted.content).assertEqual(command.content); - expect(decrypted.agent_type).assertEqual(command.agent_type); - expect(decrypted.image_contexts.length).assertEqual(1); - expect(decrypted.image_contexts[0].id).assertEqual('image-1'); - expect(decrypted.image_contexts[0].mime_type).assertEqual('image/png'); - }); - - it('produces relay-compatible encrypted payload shape', 0, async () => { - const peers = connectedPeers(); - const command: CryptoSimpleCommandFixture = { - cmd: 'get_workspace_info' - }; - const encrypted = await peers[0].encryptJson(command); - const keys = Object.keys(encrypted).sort().join(','); - const nonce = Encoding.base64ToBytes(encrypted.nonce); - const encryptedData = Encoding.base64ToBytes(encrypted.encrypted_data); - - expect(keys).assertEqual('encrypted_data,nonce'); - expect(nonce.length).assertEqual(12); - expect(encryptedData.length).assertLarger(16); - expect(encrypted.encrypted_data.indexOf('get_workspace_info')).assertEqual(-1); - const decrypted = await peers[1].decryptJson(encrypted); - expect(decrypted.cmd).assertEqual('get_workspace_info'); - }); - - it('rejects use before shared-key derivation', 0, async () => { - let didThrow = false; - try { - const command: CryptoSimpleCommandFixture = { - cmd: 'ping' - }; - const privateKey = hexToBytes('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'); - await new RemoteCrypto({ - cipher: new FakeRemoteCryptoCipher(), - keyPair: { - privateKey, - publicKey: X25519.scalarMultBase(privateKey) - }, - randomBytes: fixedRandomBytes - }).encryptJson(command); - } catch (_err) { - didThrow = true; - } - expect(didThrow).assertEqual(true); - }); - }); - - describe('TimeFormat', () => { - const now = Date.parse('2026-06-10T12:00:00.000Z'); - - it('formats recent timestamps as just now', 0, () => { - expect(TimeFormat.relative('2026-06-10T11:59:45.000Z', now)).assertEqual('刚刚'); - }); - - it('formats minute and hour relative timestamps', 0, () => { - expect(TimeFormat.relative('2026-06-10T11:42:00.000Z', now)).assertEqual('18 分钟前'); - expect(TimeFormat.relative('2026-06-10T09:00:00.000Z', now)).assertEqual('3 小时前'); - }); - - it('formats older timestamps as days or dates', 0, () => { - expect(TimeFormat.relative('2026-06-08T12:00:00.000Z', now)).assertEqual('2 天前'); - expect(TimeFormat.relative('2026-05-30T12:00:00.000Z', now)).assertEqual('2026-05-30'); - }); - - it('does not show invalid timestamps as just now', 0, () => { - expect(TimeFormat.relative('', now)).assertEqual('时间未知'); - expect(TimeFormat.relative('not-a-date', now)).assertEqual('时间未知'); - }); - - it('formats numeric timestamps and future timestamps safely', 0, () => { - expect(TimeFormat.relative('1781091720', now)).assertEqual('18 分钟前'); - expect(TimeFormat.timestampMs('1781091720000')).assertEqual(now - 18 * 60 * 1000); - expect(TimeFormat.timestampMs('1781091720000000')).assertEqual(now - 18 * 60 * 1000); - expect(TimeFormat.timestampMs('1781091720000000000')).assertEqual(now - 18 * 60 * 1000); - expect(TimeFormat.relative('2026-06-12T12:00:00.000Z', now)).assertEqual('2026-06-12'); - }); - }); - - describe('GeneralChatRepository', () => { - it('persists a complete ordinary chat and restores it without Remote state', 0, async () => { - const localStore = new FakeGeneralChatLocalStore(); - const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); - const session = await repository.createSession('帮我整理一份发布计划'); - const result = await repository.sendMessage(session.id, '先给我三个步骤'); - await repository.recordAssistantMessage(session.id, result.assistantMessage); - - expect(repository.listSessions().length).assertEqual(1); - expect(repository.listSessions()[0].agentType).assertEqual('chat'); - expect(repository.listSessions()[0].messageCount).assertEqual(2); - - const restored = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); - await restored.restoreFromLocal(); - const restoredMessages = await restored.messages(session.id); - expect(restored.listSessions().length).assertEqual(1); - expect(restoredMessages.length).assertEqual(2); - expect(restoredMessages[0].role).assertEqual('user'); - expect(restoredMessages[1].role).assertEqual('assistant'); - }); - - it('renames ordinary chat sessions through the local repository', 0, async () => { - const localStore = new FakeGeneralChatLocalStore(); - const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); - const session = await repository.createSession('原始标题'); - await repository.renameSession(session.id, '新的标题'); - - expect(repository.listSessions()[0].title).assertEqual('新的标题'); - expect(localStore.sessions[0].title).assertEqual('新的标题'); - }); - - it('archives ordinary chat sessions and restores the archived state', 0, async () => { - const localStore = new FakeGeneralChatLocalStore(); - const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); - const session = await repository.createSession('待归档会话'); - await repository.archiveSession(session.id, true); - - const restored = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); - await restored.restoreFromLocal(); - expect(restored.listSessions()[0].status).assertEqual('archived'); - - await restored.archiveSession(session.id, false); - expect(restored.listSessions()[0].status).assertEqual('ready'); - expect(localStore.sessions[0].status).assertEqual('ready'); - }); - - it('persists drafts independently and removes session-owned data on delete', 0, async () => { - const localStore = new FakeGeneralChatLocalStore(); - const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); - const session = await repository.createSession('待删除会话'); - await repository.saveDraft('new-chat', '首页草稿'); - await repository.saveDraft(session.id, '会话草稿'); - await repository.sendMessage(session.id, '一条消息'); - - expect(await repository.draft('new-chat')).assertEqual('首页草稿'); - expect(await repository.draft(session.id)).assertEqual('会话草稿'); - await repository.deleteSession(session.id); - - expect(repository.listSessions().length).assertEqual(0); - expect((await repository.messages(session.id)).length).assertEqual(0); - expect(await repository.draft(session.id)).assertEqual(''); - expect(await repository.draft('new-chat')).assertEqual('首页草稿'); - }); - - it('persists a failed user message when the provider rejects before streaming', 0, async () => { - const localStore = new FakeGeneralChatLocalStore(); - const repository = new GeneralChatRepository(new FailingGeneralChatAdapter(), localStore); - const session = await repository.createSession('失败状态'); - let failed = false; - try { - await repository.sendMessage(session.id, '请重试我'); - } catch (_err) { - failed = true; - } - - const messages = await repository.messages(session.id); - expect(failed).assertEqual(true); - expect(messages.length).assertEqual(1); - expect(messages[0].status).assertEqual('failed'); - }); - - it('keeps the user message sent when a stream fails after receiving content', 0, async () => { - const localStore = new FakeGeneralChatLocalStore(); - const repository = new GeneralChatRepository(new PartialFailingGeneralChatAdapter(), localStore); - const session = await repository.createSession('流中断状态'); - try { - await repository.sendMessage(session.id, '保留这条消息'); - } catch (_err) { - } - - const messages = await repository.messages(session.id); - expect(messages.length).assertEqual(1); - expect(messages[0].status).assertEqual('sent'); - }); - }); - - describe('GeneralChatExportFormatter', () => { - it('exports visible conversation content as Markdown without hidden error detail', 0, () => { - const session: RemoteSession = { - id: 'chat-export', - title: '发布计划', - agentType: 'chat', - status: 'ready', - updatedAt: '2026-07-20T10:00:00.000Z', - createdAt: '2026-07-20T09:00:00.000Z', - messageCount: 2 - }; - const failedAssistant = chatMessage('assistant-1', 'assistant', '先检查发布清单。', 'failed'); - failedAssistant.detail = 'secret retry context'; - const markdown = GeneralChatExportFormatter.markdown(session, [ - chatMessage('user-1', 'user', '帮我整理发布计划'), - failedAssistant - ]); - - expect(markdown).assertEqual('# 发布计划\n\n## 用户\n\n帮我整理发布计划\n\n## BitFun\n\n先检查发布清单。\n'); - expect(markdown.indexOf('secret retry context')).assertEqual(-1); - }); - }); - - describe('GeneralChatStreamCoordinator', () => { - it('batches deltas into one stable active message and flushes before completion', 0, () => { - const coordinator = new GeneralChatStreamCoordinator(); - const first = coordinator.begin('chat-1', 'turn-1'); - const flushed: ChatMessage[] = []; - coordinator.append('chat-1', '第一段\n\n\n', () => true, (message: ChatMessage) => { - flushed.push(message); - }); - coordinator.append('chat-1', '第二段', () => true, (message: ChatMessage) => { - flushed.push(message); - }); - const terminal = coordinator.flush('chat-1', true); - - expect(first.id).assertEqual('active-turn-1'); - expect(terminal?.id || '').assertEqual(first.id); - expect(terminal?.text || '').assertEqual('第一段\n\n第二段'); - expect(coordinator.text()).assertEqual('第一段\n\n第二段'); - coordinator.reset(); - }); - - it('rejects late deltas after reset or from another session', 0, () => { - const coordinator = new GeneralChatStreamCoordinator(); - coordinator.begin('chat-1', 'turn-1'); - coordinator.reset(); - - expect(coordinator.append('chat-1', 'late', () => true, (_message: ChatMessage) => {})) - .assertEqual(false); - expect(coordinator.append('chat-2', 'wrong', () => true, (_message: ChatMessage) => {})) - .assertEqual(false); - expect(coordinator.hasActiveStream()).assertEqual(false); - }); - }); - - describe('GeneralChatStreamLifecycleController', () => { - it('owns stream request lifecycle state around coordinator updates', 0, () => { - const controller = new GeneralChatStreamLifecycleController(); - const active = controller.begin('chat-1'); - const flushed: ChatMessage[] = []; - - controller.setRequestInFlight(true); - expect(controller.isRequestInFlight()).assertEqual(true); - expect(active.id.indexOf('active-')).assertEqual(0); - expect(controller.append('chat-1', 'hello', () => true, (message: ChatMessage) => { - flushed.push(message); - })).assertEqual(true); - const terminal = controller.flush('chat-1', true); - expect(terminal?.text || '').assertEqual('hello'); - expect(controller.text()).assertEqual('hello'); - - controller.markRequestCancelled(); - expect(controller.takeRequestCancelled()).assertEqual(true); - expect(controller.takeRequestCancelled()).assertEqual(false); - controller.setPendingFinalMessage(chatMessage('assistant-final', 'assistant', 'stopped', 'cancelled')); - expect(controller.takePendingFinalMessage()?.id || '').assertEqual('assistant-final'); - expect(controller.takePendingFinalMessage() === undefined).assertTrue(); - - controller.setRequestInFlight(false); - controller.reset(); - expect(controller.isRequestInFlight()).assertEqual(false); - expect(controller.currentSessionId()).assertEqual(''); - expect(controller.hasActiveStream()).assertEqual(false); - }); - }); - - describe('GeneralChatServiceStatus', () => { - it('derives readiness only from complete model configuration', 0, () => { - expect(GeneralChatServiceStatus.fromConfiguration('https://api.example.com', 'model', true)) - .assertEqual(GeneralChatServiceState.Ready); - expect(GeneralChatServiceStatus.fromConfiguration('', 'model', true)) - .assertEqual(GeneralChatServiceState.Unconfigured); - }); - - it('classifies authentication, network and provider failures independently from Remote state', 0, () => { - expect(GeneralChatServiceStatus.fromError(RemoteI18n.t('generalChat.authenticationFailed'))) - .assertEqual(GeneralChatServiceState.AuthExpired); - expect(GeneralChatServiceStatus.fromError('Network connection timeout.')) - .assertEqual(GeneralChatServiceState.Offline); - expect(GeneralChatServiceStatus.fromError(RemoteI18n.t('generalChat.serviceUnavailable'))) - .assertEqual(GeneralChatServiceState.Failed); - }); - }); - - describe('ChatComposerPolicy', () => { - it('keeps ordinary chat send enabled across every Remote connection state', 0, () => { - const states = ['idle', 'pairing', 'connected', 'reconnecting', 'failed', 'disconnected']; - states.forEach((state: string) => { - expect(ChatComposerPolicy.canSend('普通问题', 0, false, false, state)).assertEqual(true); - }); - }); - - it('still gates Remote chat when the desktop connection is unavailable', 0, () => { - expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'failed')).assertEqual(false); - expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'connected')).assertEqual(true); - expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'reconnecting')).assertEqual(true); - }); - }); - - describe('GeneralChatApiClient', () => { - it('builds authenticated session and turn requests', 0, async () => { - const transport = new FakeGeneralChatHttpTransport(); - transport.responses = [{ - statusCode: 200, - body: '{"session":{"id":"chat-1","title":"计划","status":"ready","created_at":"now","updated_at":"now","message_count":0}}' - }, { - statusCode: 202, - body: '{"turn_id":"turn-1","accepted_at":"now","version":1}' - }]; - const client = new GeneralChatApiClient( - 'https://chat.example.com/', - new FakeGeneralChatTokenProvider(), - transport - ); - - const session = await client.createSession(' 计划 '); - const receipt = await client.sendTurn(session.session.id, ' 生成步骤 ', ' chat-model '); - - expect(session.session.id).assertEqual('chat-1'); - expect(receipt.turn_id).assertEqual('turn-1'); - expect(transport.requests[0].url).assertEqual('https://chat.example.com/api/mobile/v1/chat/sessions'); - expect(transport.requests[0].authorization).assertEqual('Bearer test-token'); - expect(transport.requests[0].body).assertEqual('{"title":"计划"}'); - expect(transport.requests[1].method).assertEqual(GeneralChatHttpMethod.Post); - expect(transport.requests[1].body).assertEqual('{"content":"生成步骤","model":"chat-model"}'); - }); - - it('maps authentication failures without exposing response bodies', 0, async () => { - const transport = new FakeGeneralChatHttpTransport(); - transport.responses = [{ statusCode: 401, body: '{"token":"must-not-leak"}' }]; - const client = new GeneralChatApiClient( - 'https://chat.example.com', - new FakeGeneralChatTokenProvider(), - transport - ); - let message = ''; - try { - await client.capabilities(); - } catch (err) { - message = err instanceof Error ? err.message : `${err}`; - } - - expect(message).assertEqual('General chat authentication expired.'); - expect(message.indexOf('must-not-leak')).assertEqual(-1); - }); - }); - - describe('GeneralChatConfigValidator', () => { - it('accepts an existing key when URL and model are valid', 0, () => { - const error = GeneralChatConfigValidator.validate({ - apiUrl: 'https://chat.example.com/', - apiKey: '', - modelName: 'chat-model', - clearApiKey: false - }, true); - expect(error).assertEqual(''); - }); - - it('rejects unsafe URLs and incomplete new configurations', 0, () => { - const urlError = GeneralChatConfigValidator.validate({ - apiUrl: 'file:///tmp/chat', - apiKey: 'secret', - modelName: 'chat-model', - clearApiKey: false - }, false); - const keyError = GeneralChatConfigValidator.validate({ - apiUrl: 'https://chat.example.com', - apiKey: '', - modelName: 'chat-model', - clearApiKey: false - }, false); - expect(urlError).assertEqual('API URL 必须以 http:// 或 https:// 开头。'); - expect(keyError).assertEqual('请输入 API Key。'); - }); - }); - - describe('ModelProviderGeneralChatAdapter', () => { - it('infers provider protocol and request URL without a protocol setting', 0, () => { - const openBitFunProtocol = ModelProviderGeneralChatAdapter.resolveProtocol('https://api.openbitfun.com'); - const customProtocol = ModelProviderGeneralChatAdapter.resolveProtocol('https://llm.example.com/v1'); - - expect(openBitFunProtocol).assertEqual(ModelProviderProtocol.Anthropic); - expect(customProtocol).assertEqual(ModelProviderProtocol.OpenAi); - expect(ModelProviderGeneralChatAdapter.resolveRequestUrl( - 'https://api.openbitfun.com/', - openBitFunProtocol - )).assertEqual('https://api.openbitfun.com/v1/messages'); - expect(ModelProviderGeneralChatAdapter.resolveRequestUrl( - 'https://llm.example.com/v1', - customProtocol - )).assertEqual('https://llm.example.com/v1/chat/completions'); - }); - - it('extracts text from Anthropic and OpenAI compatible responses', 0, () => { - const anthropicText = ModelProviderGeneralChatAdapter.responseText( - '{"content":[{"type":"text","text":"你好"}]}', - ModelProviderProtocol.Anthropic - ); - const openAiText = ModelProviderGeneralChatAdapter.responseText( - '{"choices":[{"message":{"content":"Hello"}}]}', - ModelProviderProtocol.OpenAi - ); - - expect(anthropicText).assertEqual('你好'); - expect(openAiText).assertEqual('Hello'); - }); - - it('places the BitFun system prompt in both request formats', 0, () => { - const messages: ModelProviderMessage[] = [{ role: 'user', content: 'BitFun 是什么?' }]; - const anthropicBody = ModelProviderGeneralChatAdapter.buildRequestBody( - 'model-a', - messages, - ModelProviderProtocol.Anthropic - ); - const openAiBody = ModelProviderGeneralChatAdapter.buildRequestBody( - 'model-b', - messages, - ModelProviderProtocol.OpenAi - ); - - expect(anthropicBody.indexOf('local AI workbench')).assertLarger(-1); - expect(anthropicBody.indexOf('"system"')).assertLarger(-1); - expect(openAiBody.indexOf('"role":"system"')).assertLarger(-1); - expect(openAiBody.indexOf('blockchain')).assertLarger(-1); - }); - }); - - describe('ModelProviderSseParser', () => { - it('parses Anthropic deltas split across network chunks', 0, () => { - const parser = new ModelProviderSseParser(ModelProviderProtocol.Anthropic); - const first = parser.feed( - 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg-1"}}\n\n' + - 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_' - ); - const second = parser.feed('delta","text":"你好"}}\n\n'); - const third = parser.feed( - 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"!"}}\n\n' - ); - - expect(first.length).assertEqual(0); - expect(second.join('')).assertEqual('你好'); - expect(third.join('')).assertEqual('!'); - expect(parser.responseId()).assertEqual('msg-1'); - }); - - it('parses OpenAI CRLF events and ignores the done marker', 0, () => { - const parser = new ModelProviderSseParser(ModelProviderProtocol.OpenAi); - const deltas = parser.feed( - 'data: {"id":"chat-1","choices":[{"delta":{"content":"Hello"}}]}\r\n\r\n' + - 'data: {"id":"chat-1","choices":[{"delta":{"content":" world"}}]}\r\n\r\n' + - 'data: [DONE]\r\n\r\n' - ); - - expect(deltas.join('')).assertEqual('Hello world'); - expect(parser.responseId()).assertEqual('chat-1'); - expect(parser.finish().length).assertEqual(0); - }); - }); - - describe('GeneralChatEventMapper', () => { - it('projects ordered stream deltas and ignores duplicate versions', 0, () => { - let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'event-1', - version: 1, - session_id: 'chat-1', - turn_id: 'turn-1', - type: 'message.delta', - delta: '你好' - }); - const duplicate = GeneralChatEventMapper.apply(projection, { - event_id: 'event-1', - version: 1, - session_id: 'chat-1', - turn_id: 'turn-1', - type: 'message.delta', - delta: '不应重复' - }); - projection = GeneralChatEventMapper.apply(duplicate, { - event_id: 'event-2', - version: 2, - session_id: 'chat-1', - turn_id: 'turn-1', - type: 'turn.completed' - }); - - expect(projection.text).assertEqual('你好'); - expect(projection.status).assertEqual('completed'); - expect(projection.version).assertEqual(2); - }); - - it('rejects stream version gaps so callers can reload a snapshot', 0, () => { - const projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1', 3); - let didThrow = false; - try { - GeneralChatEventMapper.apply(projection, { - event_id: 'event-5', - version: 5, - session_id: 'chat-1', - turn_id: 'turn-1', - type: 'message.delta', - delta: '缺少版本 4' - }); - } catch (_err) { - didThrow = true; - } - expect(didThrow).assertEqual(true); - }); - - it('preserves text and tool event order while updating a stable tool item', 0, () => { - let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'text-1', version: 1, session_id: 'chat-1', turn_id: 'turn-1', - type: 'message.delta', delta: 'before' - }); - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'tool-event-1', version: 2, session_id: 'chat-1', turn_id: 'turn-1', - type: 'tool.started', tool: { id: 'tool-1', name: 'WebSearch', status: 'running' } - }); - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'tool-event-2', version: 3, session_id: 'chat-1', turn_id: 'turn-1', - type: 'tool.completed', tool: { id: 'tool-1', name: 'WebSearch', status: 'completed' } - }); - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'text-2', version: 4, session_id: 'chat-1', turn_id: 'turn-1', - type: 'message.delta', delta: 'after' - }); - - expect(projection.items.length).assertEqual(3); - expect(projection.items[0].content).assertEqual('before'); - expect(projection.items[1].tool?.id || '').assertEqual('tool-1'); - expect(projection.items[1].tool?.status || '').assertEqual('completed'); - expect(projection.items[2].content).assertEqual('after'); - expect(projection.tools.length).assertEqual(1); - }); - - it('projects permission and capability states and ignores late terminal events', 0, () => { - let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'permission-1', version: 1, session_id: 'chat-1', turn_id: 'turn-1', - type: 'permission.required', - tool: { id: 'tool-1', name: 'Write', status: 'waiting' } - }); - expect(projection.status).assertEqual('pending_confirmation'); - expect(projection.tools[0].status || '').assertEqual('pending_confirmation'); - - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'capability-1', version: 2, session_id: 'chat-1', turn_id: 'turn-1', - type: 'capability.required', capability: 'remote_workspace' - }); - expect(projection.status).assertEqual('capability_required'); - expect(projection.capabilityRequired).assertEqual('remote_workspace'); - - projection = GeneralChatEventMapper.apply(projection, { - event_id: 'cancel-1', version: 3, session_id: 'chat-1', turn_id: 'turn-1', - type: 'turn.cancelled' - }); - const terminal = GeneralChatEventMapper.apply(projection, { - event_id: 'late-1', version: 4, session_id: 'chat-1', turn_id: 'turn-1', - type: 'message.delta', delta: 'late' - }); - expect(terminal.status).assertEqual('cancelled'); - expect(terminal.version).assertEqual(3); - expect(terminal.text).assertEqual(''); - }); - }); - - describe('RemoteCommandFactory', () => { - it('builds workspace and assistant commands', 0, () => { - expectCommandJson(RemoteCommandFactory.getWorkspaceInfo(), '{"cmd":"get_workspace_info"}'); - expectCommandJson(RemoteCommandFactory.listRecentWorkspaces(), '{"cmd":"list_recent_workspaces"}'); - expectCommandJson(RemoteCommandFactory.setWorkspace('/repo'), '{"cmd":"set_workspace","path":"/repo"}'); - expectCommandJson(RemoteCommandFactory.listAssistants(), '{"cmd":"list_assistants"}'); - expectCommandJson(RemoteCommandFactory.setAssistant('/assistant'), '{"cmd":"set_assistant","path":"/assistant"}'); - }); - - it('builds session list and creation commands without unsupported fields', 0, () => { - const listCommand = RemoteCommandFactory.listSessions('/repo', 8, 16, ' auth '); - expectCommandJson(listCommand, '{"cmd":"list_sessions","workspace_path":"/repo","limit":8,"offset":16,"query":"auth"}'); - expect(JSON.stringify(listCommand).indexOf('agent_type')).assertEqual(-1); - - const createCommand = RemoteCommandFactory.createSession({ - agentType: 'cowork', - title: '', - instruction: 'hello' - }, '/repo'); - expectCommandJson(createCommand, '{"cmd":"create_session","agent_type":"cowork","session_name":"Remote Cowork Session","workspace_path":"/repo"}'); - expect(JSON.stringify(createCommand).indexOf('run_mode')).assertEqual(-1); - }); - - it('builds message and polling commands', 0, () => { - expectCommandJson(RemoteCommandFactory.sendMessage('s1', 'hello', 'code'), '{"cmd":"send_message","session_id":"s1","content":"hello","agent_type":"code"}'); - expectCommandJson(RemoteCommandFactory.sendMessage('s1', 'look', 'code', [{ - id: 'img1', - data_url: 'data:image/png;base64,abc', - mime_type: 'image/png' - }]), '{"cmd":"send_message","session_id":"s1","content":"look","agent_type":"code","image_contexts":[{"id":"img1","data_url":"data:image/png;base64,abc","mime_type":"image/png"}]}'); - expectCommandJson(RemoteCommandFactory.getSessionMessages('s1'), '{"cmd":"get_session_messages","session_id":"s1"}'); - expectCommandJson(RemoteCommandFactory.pollSession('s1', 7, 3, 2), '{"cmd":"poll_session","session_id":"s1","since_version":7,"known_msg_count":3,"known_model_catalog_version":2}'); - }); - - it('builds model and session lifecycle commands', 0, () => { - expectCommandJson(RemoteCommandFactory.getModelCatalog('s1'), '{"cmd":"get_model_catalog","session_id":"s1"}'); - expectCommandJson(RemoteCommandFactory.setSessionModel('s1', 'model-a'), '{"cmd":"set_session_model","session_id":"s1","model_id":"model-a"}'); - expectCommandJson(RemoteCommandFactory.cancelTask('s1'), '{"cmd":"cancel_task","session_id":"s1"}'); - expectCommandJson(RemoteCommandFactory.cancelTask('s1', 'turn-9'), '{"cmd":"cancel_task","session_id":"s1","turn_id":"turn-9"}'); - expectCommandJson(RemoteCommandFactory.renameSession('s1', 'Next'), '{"cmd":"update_session_title","session_id":"s1","title":"Next"}'); - expectCommandJson(RemoteCommandFactory.deleteSession('s1'), '{"cmd":"delete_session","session_id":"s1"}'); - expectCommandJson(RemoteCommandFactory.ping(), '{"cmd":"ping"}'); - }); - - it('builds tool and file commands', 0, () => { - const answers: RemoteQuestionAnswerPayload = { answer: 'yes', '0': 'yes' }; - - expectCommandJson(RemoteCommandFactory.confirmTool('tool-1'), '{"cmd":"confirm_tool","tool_id":"tool-1"}'); - expectCommandJson(RemoteCommandFactory.rejectTool('tool-1', 'no'), '{"cmd":"reject_tool","tool_id":"tool-1","reason":"no"}'); - expectCommandJson(RemoteCommandFactory.cancelTool('tool-1', 'stop'), '{"cmd":"cancel_tool","tool_id":"tool-1","reason":"stop"}'); - expectCommandJson(RemoteCommandFactory.answerQuestion('tool-2', answers), '{"cmd":"answer_question","tool_id":"tool-2","answers":{"0":"yes","answer":"yes"}}'); - expectCommandJson(RemoteCommandFactory.getFileInfo('/tmp/a.txt', 's1'), '{"cmd":"get_file_info","path":"/tmp/a.txt","session_id":"s1"}'); - expectCommandJson(RemoteCommandFactory.readFileChunk('/tmp/a.txt', 12, 64, 's1'), '{"cmd":"read_file_chunk","path":"/tmp/a.txt","offset":12,"limit":64,"session_id":"s1"}'); - }); - }); - - describe('RemoteResponseMapper', () => { - it('maps session response items', 0, () => { - const sessions = RemoteResponseMapper.sessions([{ - session_id: 'session-abcdef', - name: 'Fallback title', - agent_type: 'agentic', - status: 'running', - updated_at: '2026-06-10T12:00:00.000Z', - created_at: '2026-06-09T12:00:00.000Z', - message_count: 4, - workspace_path: '/repo', - workspace_name: 'Repo' - }]); - expect(sessions.length).assertEqual(1); - expect(sessions[0].id).assertEqual('session-abcdef'); - expect(sessions[0].title).assertEqual('Fallback title'); - expect(sessions[0].agentType).assertEqual('agentic'); - expect(sessions[0].messageCount).assertEqual(4); - expect(sessions[0].workspaceName).assertEqual('Repo'); - }); - - it('maps alternate session time fields', 0, () => { - const sessions = RemoteResponseMapper.sessions([{ - session_id: 'session-time', - title: 'Alternate time fields', - last_message_at: 1781092800000, - createdAt: '2026-06-09 12:00:00', - message_count: 1 - }]); - expect(sessions.length).assertEqual(1); - expect(sessions[0].updatedAt).assertEqual('1781092800000'); - expect(sessions[0].createdAt).assertEqual('2026-06-09 12:00:00'); - }); - - it('maps assistant messages and nested tools', 0, () => { - const message = RemoteResponseMapper.chatMessage({ - id: 'm1', - role: 'assistant', - content: '', - thinking: 'Thinking out loud', - timestamp: '2026-06-10T12:00:00.000Z', - items: [{ - type: 'tool', - tool: { - id: 'tool-1', - name: 'shell', - status: 'pending' - }, - subItems: [{ - type: 'tool', - tool: { - id: 'tool-2', - name: 'edit', - status: 'done' - } - }] - }] - }); - expect(message.id).assertEqual('m1'); - expect(message.text).assertEqual(''); - expect(message.thinking || '').assertEqual('Thinking out loud'); - expect(message.status).assertEqual('done'); - expect(message.detail).assertEqual('shell · pending\nedit · done'); - expect(message.tools ? message.tools.length : 0).assertEqual(2); - }); - - it('uses fallback ids for message rendering keys', 0, () => { - const fromMessageId = RemoteResponseMapper.chatMessage({ - message_id: 'desktop-message-1', - role: 'assistant', - content: 'Mapped from alternate id', - timestamp: '2026-06-10T12:00:00.000Z' - }); - const generated = RemoteResponseMapper.chatMessage({ - role: 'user', - content: 'No id in response', - timestamp: '2026-06-10T12:01:00.000Z' - }); - - expect(fromMessageId.id).assertEqual('desktop-message-1'); - expect(generated.id.length > 0).assertTrue(); - expect(generated.id.indexOf('user-2026-06-10T12:01:00.000Z-')).assertEqual(0); - }); - - it('maps active turns with item fallback text and tool detail', 0, () => { - const message = RemoteResponseMapper.activeTurnToMessage({ - turn_id: 'turn-1', - status: 'active', - items: [{ - type: 'text', - content: 'Working on it' - }], - tools: [{ - id: 'tool-3', - name: 'read_file', - status: 'running' - }] - }); - expect(message.id).assertEqual('active-turn-1'); - expect(message.turnId || '').assertEqual('turn-1'); - expect(message.role).assertEqual('assistant'); - expect(message.text).assertEqual('Working on it'); - expect(message.status).assertEqual('active'); - expect(message.detail).assertEqual('read_file · running'); - }); - - it('does not promote subagent progress to active turn text', 0, () => { - const message = RemoteResponseMapper.activeTurnToMessage({ - turn_id: 'turn-structured', - status: 'active', - items: [{ - type: 'subagent', - is_subagent: true, - content: 'Subagent check', - subItems: [{ - type: 'text', - content: 'Nested progress' - }, { - type: 'tool', - tool: { - id: 'tool-nested-1', - name: 'inspect', - status: 'running' - } - }] - }] - }); - - expect(message.text).assertEqual(''); - expect(message.tools ? message.tools.length : 0).assertEqual(1); - expect(message.detail).assertEqual('inspect · running'); - }); - - it('does not use thinking as active turn final text', 0, () => { - const message = RemoteResponseMapper.activeTurnToMessage({ - turn_id: 'turn-thinking-only', - status: 'completed', - thinking: 'Still reasoning', - items: [{ - type: 'thinking', - content: 'Still reasoning' - }] - }); - - expect(message.text).assertEqual(''); - expect(message.thinking || '').assertEqual('Still reasoning'); - expect(message.status).assertEqual('completed'); - }); - }); - - describe('ChatTimelineProjector', () => { - it('filters seed messages and returns empty state for empty real history', 0, () => { - const items = ChatTimelineProjector.project([ - chatMessage('system-seed', 'assistant', 'Welcome') - ], [], chatMessage('', 'assistant', ''), false); - - expect(items.length).assertEqual(1); - expect(items[0].type).assertEqual('empty_state'); - }); - - it('deduplicates optimistic user messages replaced by persisted messages', 0, () => { - const items = ChatTimelineProjector.project([ - chatMessage('remote-user-1', 'user', 'Explain this file') - ], [ - chatMessage('msg-local-1', 'user', ' Explain this file ') - ], chatMessage('', 'assistant', ''), false); - - expect(items.length).assertEqual(1); - expect(items[0].id).assertEqual('message-remote-user-1'); - expect(items[0].type).assertEqual('user_message'); - }); - - it('keeps optimistic image messages when persisted image payload differs', 0, () => { - const items = ChatTimelineProjector.project([ - chatMessageWithImage('remote-user-1', 'user', 'Analyze image', 'remote.png', 'data:image/png;base64,remote') - ], [ - chatMessageWithImage('msg-local-1', 'user', 'Analyze image', 'local.png', 'data:image/png;base64,local') - ], chatMessage('', 'assistant', ''), false); - - expect(items.length).assertEqual(2); - expect(items[0].id).assertEqual('message-remote-user-1'); - expect(items[1].id).assertEqual('pending-msg-local-1'); - expect(items[1].type).assertEqual('optimistic_user_message'); - }); - - it('keeps active turn until matching assistant message is persisted', 0, () => { - const activeTurn = chatMessage('active-turn-1', 'assistant', 'Done with the edit', 'completed'); - const beforeFinal = ChatTimelineProjector.project([ - chatMessage('remote-user-1', 'user', 'Please edit') - ], [], activeTurn, false); - const afterFinal = ChatTimelineProjector.project([ - chatMessage('remote-user-1', 'user', 'Please edit'), - chatMessage('assistant-final-1', 'assistant', 'Done with the edit') - ], [], activeTurn, false); - - expect(beforeFinal.length).assertEqual(2); - expect(beforeFinal[1].type).assertEqual('assistant_live_turn'); - expect(beforeFinal[1].isFinalizing).assertEqual(true); - expect(afterFinal.length).assertEqual(2); - expect(afterFinal[1].id).assertEqual('message-assistant-final-1'); - expect(afterFinal[1].type).assertEqual('assistant_message'); - }); - - it('updates active turn item id when visible content changes', 0, () => { - const first = activeChatMessage('turn-stable-1', 'abc', 'active', 1); - const second = activeChatMessage('turn-stable-1', 'abcdef', 'active', 2); - const firstItems = ChatTimelineProjector.project([], [], first, false); - const secondItems = ChatTimelineProjector.project([], [], second, false); - - expect(firstItems.length).assertEqual(1); - expect(secondItems.length).assertEqual(1); - expect(firstItems[0].id.indexOf('active-turn-stable-1-')).assertEqual(0); - expect(secondItems[0].id.indexOf('active-turn-stable-1-')).assertEqual(0); - expect(secondItems[0].id === firstItems[0].id).assertFalse(); - }); - - it('hides active turn when final assistant id matches turn id', 0, () => { - const activeTurn = activeChatMessage('turn-final-1', 'partial active text', 'completed'); - const items = ChatTimelineProjector.project([ - chatMessage('turn-final-1_assistant', 'assistant', 'rewritten final text') - ], [], activeTurn, false); - - expect(items.length).assertEqual(1); - expect(items[0].id).assertEqual('message-turn-final-1_assistant'); - expect(items[0].type).assertEqual('assistant_message'); - }); - - it('keeps active turn when matching persisted assistant has no final text yet', 0, () => { - const activeTurn = activeChatMessage('turn-final-wait-1', 'final answer', 'completed'); - const pendingFinal = chatMessage('turn-final-wait-1_assistant', 'assistant', ''); - pendingFinal.text = 'Still reasoning'; - pendingFinal.thinking = 'Still reasoning'; - const items = ChatTimelineProjector.project([pendingFinal], [], activeTurn, false); - - expect(items.length).assertEqual(2); - expect(items[1].type).assertEqual('assistant_live_turn'); - expect(items[1].isFinalizing).assertEqual(true); - }); - }); - - describe('ChatTimelineStore', () => { - it('updates same-id persisted messages when final content arrives later', 0, () => { - const pendingFinal = chatMessage('assistant-final-same-id', 'assistant', ''); - pendingFinal.text = 'Still reasoning'; - pendingFinal.thinking = 'Still reasoning'; - const merged = RemoteUiState.mergeMessages([pendingFinal], [ - chatMessage('assistant-final-same-id', 'assistant', 'Final answer') - ]); - - expect(merged.length).assertEqual(1); - expect(merged[0].text).assertEqual('Final answer'); - expect(merged[0].thinking || '').assertEqual(''); - }); - - it('owns optimistic merge and active turn cleanup rules', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - store.appendOptimisticMessage(chatMessage('msg-local-1', 'user', 'Please edit')); - store.setActiveTurn(chatMessage('active-turn-1', 'assistant', 'Final text', 'completed')); - store.mergePersistedMessages([ - chatMessage('remote-user-1', 'user', 'Please edit') - ]); - - let state = store.snapshot(); - expect(state.persistedMessages.length).assertEqual(1); - expect(state.optimisticMessages.length).assertEqual(0); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-1'); - expect(state.syncPhase).assertEqual('finalizing'); - - store.mergePersistedMessages([ - chatMessage('assistant-final-1', 'assistant', 'Final text') - ]); - state = store.snapshot(); - expect(state.persistedMessages.length).assertEqual(2); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); - }); - - it('projects empty state through the store', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-empty'); - const items = store.project(false); - - expect(items.length).assertEqual(1); - expect(items[0].type).assertEqual('empty_state'); - }); - - it('creates a local active turn from send response turn ids', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - store.setLocalActiveTurn(' turn-preview-1 '); - - let state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-preview-1'); - expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-preview-1'); - expect(state.activeTurn ? state.activeTurn.status : '').assertEqual('active'); - expect(state.syncPhase).assertEqual('streaming'); - - store.setLocalActiveTurn('turn-preview-2'); - state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-preview-1'); - }); - - it('replaces pending active turns with remote turn ids', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - const pendingId = store.setPendingActiveTurn('msg-local-1'); - - let state = store.snapshot(); - expect(pendingId).assertEqual('active-pending-msg-local-1'); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-pending-msg-local-1'); - expect(state.syncPhase).assertEqual('streaming'); - - store.setLocalActiveTurn('turn-remote-1'); - state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-remote-1'); - expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-remote-1'); - }); - - it('keeps streamed active turn text when send response arrives later', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - const active = chatMessage('active-turn-remote-1', 'assistant', 'partial text', 'active'); - active.turnId = 'turn-remote-1'; - store.setActiveTurn(active); - - store.setLocalActiveTurn('turn-remote-1'); - - const state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-remote-1'); - expect(state.activeTurn ? state.activeTurn.text : '').assertEqual('partial text'); - expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-remote-1'); - }); - - it('keeps active turn text from going backwards for the same turn id', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - store.setActiveTurn(activeChatMessage('turn-stream-1', 'abcdef')); - store.setActiveTurn(activeChatMessage('turn-stream-1', 'abc', 'active', 2)); - - const state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.text : '').assertEqual('abcdef'); - expect(state.activeTurn ? (state.activeTurn.turnId || '') : '').assertEqual('turn-stream-1'); - expect(state.activeTurn ? (state.activeTurn.renderVersion || 0) : 0).assertEqual(2); - }); - - it('monotonically merges structured text items for active turns', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - const first = activeChatMessage('turn-items-1', ''); - first.items = [{ - type: 'text', - content: 'abcdef' - }]; - const second = activeChatMessage('turn-items-1', '', 'active', 2); - second.items = [{ - type: 'text', - content: 'abc' - }]; - - store.setActiveTurn(first); - store.setActiveTurn(second); - - const state = store.snapshot(); - const items = state.activeTurn && state.activeTurn.items ? state.activeTurn.items : []; - expect(items.length).assertEqual(1); - expect(items[0].content || '').assertEqual('abcdef'); - }); - - it('updates active tool status from latest structured item snapshot', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - const first = activeChatMessage('turn-tool-1', ''); - first.items = [{ - type: 'tool', - tool: { - id: 'tool-1', - name: 'read_file', - status: 'running' - } - }]; - const second = activeChatMessage('turn-tool-1', '', 'active', 2); - second.items = [{ - type: 'tool', - tool: { - id: 'tool-1', - name: 'read_file', - status: 'completed', - duration_ms: 50 - } - }]; - - store.setActiveTurn(first); - store.setActiveTurn(second); - - const state = store.snapshot(); - const items = state.activeTurn && state.activeTurn.items ? state.activeTurn.items : []; - expect(items.length).assertEqual(1); - expect(items[0].tool ? (items[0].tool.status || '') : '').assertEqual('completed'); - expect(items[0].tool ? (items[0].tool.duration_ms || 0) : 0).assertEqual(50); - }); - - it('clears active turn when persisted assistant id matches turn id', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - store.setActiveTurn(activeChatMessage('turn-final-store-1', 'partial active text', 'completed')); - store.mergePersistedMessages([ - chatMessage('turn-final-store-1_assistant', 'assistant', 'rewritten final text') - ]); - - const state = store.snapshot(); - expect(state.persistedMessages.length).assertEqual(1); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); - }); - - it('holds completed active turn until persisted assistant has final content', 0, () => { - const store = new ChatTimelineStore(); - store.reset('session-1'); - store.setActiveTurn(activeChatMessage('turn-final-store-wait-1', 'final answer', 'completed')); - - const pendingFinal = chatMessage('turn-final-store-wait-1_assistant', 'assistant', ''); - pendingFinal.text = 'Still reasoning'; - pendingFinal.thinking = 'Still reasoning'; - store.mergePersistedMessages([pendingFinal]); - store.setActiveTurn(undefined); - - let state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual('active-turn-final-store-wait-1'); - expect(state.syncPhase).assertEqual('finalizing'); - - store.mergePersistedMessages([ - chatMessage('turn-final-store-wait-1_assistant', 'assistant', 'final answer') - ]); - state = store.snapshot(); - expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); - }); - }); - - describe('MarkdownParser', () => { - it('parses common markdown blocks into stable block types', 0, () => { - const blocks = MarkdownParser.parse('# Title\n\n- first\n- **second**\n\n> quote\n\n```ts\nconst value = 1;\n```\n\n| A | B |\n| --- | --- |\n| 1 | 2 |'); - - expect(blocks.length).assertEqual(5); - expect(blocks[0].type).assertEqual('heading'); - expect(blocks[0].level).assertEqual(1); - expect(blocks[1].type).assertEqual('list'); - expect(blocks[1].items.length).assertEqual(2); - expect(blocks[1].items[1].text).assertEqual('second'); - expect(blocks[2].type).assertEqual('quote'); - expect(blocks[3].type).assertEqual('code'); - expect(blocks[3].language).assertEqual('ts'); - expect(blocks[4].type).assertEqual('table'); - }); - - it('formats inline code, emphasis, and links without markdown control text', 0, () => { - const blocks = MarkdownParser.parse('Use `pnpm test`, **bold text**, and [docs](https://example.test).'); - - expect(blocks.length).assertEqual(1); - expect(blocks[0].type).assertEqual('paragraph'); - expect(blocks[0].text).assertEqual('Use pnpm test, bold text, and docs (https://example.test).'); - expect(blocks[0].inlines.length).assertEqual(7); - expect(blocks[0].inlines[1].type).assertEqual('code'); - expect(blocks[0].inlines[3].type).assertEqual('strong'); - expect(blocks[0].inlines[5].type).assertEqual('link'); - expect(blocks[0].inlines[5].url).assertEqual('https://example.test'); - }); - }); - - describe('GeneralChatPageState', () => { - it('projects configuration, busy state, and status text', 0, () => { - const state = new GeneralChatPageState(); - state.setConfiguration( - 'https://api.openbitfun.com', - 'deepseek-v4-flash', - true, - GeneralChatServiceState.Ready - ); - state.setBusy(true); - state.setStatus('Generating'); - - expect(state.apiUrl).assertEqual('https://api.openbitfun.com'); - expect(state.modelName).assertEqual('deepseek-v4-flash'); - expect(state.hasApiKey).assertTrue(); - expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); - expect(state.isBusy).assertTrue(); - expect(state.statusText).assertEqual('Generating'); - }); - - it('owns a copied session list and returns it in recent order', 0, () => { - const state = new GeneralChatPageState(); - const older = remoteSession('chat-older', 'Older chat'); - const newer = remoteSession('chat-newer', 'Newer chat'); - older.updatedAt = '2026-07-20T00:00:00.000Z'; - newer.updatedAt = '2026-07-21T00:00:00.000Z'; - const sessions = [older, newer]; - - state.setSessions(sessions); - sessions.length = 0; - - const recent = state.recentSessions(); - expect(state.sessions.length).assertEqual(2); - expect(recent[0].id).assertEqual('chat-newer'); - expect(recent[1].id).assertEqual('chat-older'); - }); - - it('owns active session and timeline projection copies', 0, () => { - const state = new GeneralChatPageState(); - const userMessage = chatMessage('user-1', 'user', 'Plan the refactor'); - const pendingMessage = chatMessage('pending-1', 'user', 'Retry this'); - const activeTurn = activeChatMessage('turn-1', 'Working'); - const timelineItems: ChatTimelineItem[] = [{ - id: 'message-user-1', - type: 'user_message', - message: userMessage, - isStreaming: false, - isFinalizing: false - }]; - - state.setActiveSession({ - sessionId: 'chat-1', - title: 'Architecture', - workspacePath: '', - agentType: 'chat' - }); - state.setTimelineProjection([userMessage], [pendingMessage], activeTurn, true, timelineItems); - timelineItems.length = 0; - - expect(state.activeSession.sessionId).assertEqual('chat-1'); - expect(state.activeSession.agentType).assertEqual('chat'); - expect(state.persistedMessages.length).assertEqual(1); - expect(state.optimisticMessages.length).assertEqual(1); - expect(state.timelineItems.length).assertEqual(1); - expect(state.hasMoreMessages).assertTrue(); - expect(state.hasRunningActiveTurn()).assertTrue(); - expect(state.latestUserMessageText()).assertEqual('Retry this'); - }); - - it('copies nested session projection and replaces streaming turn snapshots', 0, () => { - const state = new GeneralChatPageState(); - const session: SessionSummary = { - sessionId: 'chat-nested-1', - title: 'Original title', - workspacePath: '/must-not-leak', - agentType: 'code', - initialTurnId: 'turn-initial' - }; - const firstTurn = activeChatMessage('turn-stream-1', 'Hel', 'active', 1); - const secondTurn = activeChatMessage('turn-stream-1', 'Hello', 'active', 2); - - state.setActiveSession(session); - session.sessionId = 'mutated-session'; - session.title = 'Mutated title'; - - state.setTimelineProjection([], [], firstTurn, false, []); - firstTurn.text = 'mutated first turn'; - state.setTimelineProjection([], [], secondTurn, false, []); - - expect(state.activeSession.sessionId).assertEqual('chat-nested-1'); - expect(state.activeSession.title).assertEqual('Original title'); - expect(state.activeSession.agentType).assertEqual('chat'); - expect(state.activeSession.workspacePath).assertEqual('/must-not-leak'); - expect(state.activeTurnMessage.text).assertEqual('Hello'); - expect(state.activeTurnMessage.renderVersion || 0).assertEqual(2); - expect(state.hasRunningActiveTurn()).assertTrue(); - }); - - it('owns ordinary chat composer projection', 0, () => { - const state = new GeneralChatPageState(); - const images = [selectedImage('general-image-1')]; - - state.setChatInput('Draft text'); - state.setSelectedImages(images); - state.setVoiceListening(true); - images.length = 0; - - expect(state.chatInput).assertEqual('Draft text'); - expect(state.selectedImages.length).assertEqual(1); - expect(state.isVoiceListening).assertTrue(); - - state.removeSelectedImage('general-image-1'); - expect(state.selectedImages.length).assertEqual(0); - - state.clearComposer(); - state.setVoiceListening(false); - expect(state.chatInput).assertEqual(''); - expect(state.selectedImages.length).assertEqual(0); - expect(state.isVoiceListening).assertFalse(); - }); - }); - - describe('RemotePageState', () => { - it('projects connection summary and filters visible sessions', 0, () => { - const state = new RemotePageState(); - state.setDesktopIdentity('Developer Mac', 'desktop-1'); - state.setPairingInput('https://relay.example.com/#/pair?room=abc', 'harmony-device', true); - state.setAuthenticatedUserId('desktop-user'); - state.setStatusText('Connected'); - state.setConnectionState('connected'); - state.setConnectionFailureKind(''); - state.setBusy(true); - state.setWorkspace('BitFun', '/workspace/BitFun', 'default', 'main', 'normal'); - state.setSessions([ - remoteSession('session-1', '/canvas draw architecture'), - remoteSession('session-2', 'Write release notes'), - remoteSession('session-3', '/canvas archived', 'archived'), - remoteSession('', '/canvas invalid') - ], true); - state.setQuery('CANVAS'); - - const visible = state.visibleSessions(); - expect(state.desktopName).assertEqual('Developer Mac'); - expect(state.desktopId).assertEqual('desktop-1'); - expect(state.remoteUrl).assertEqual('https://relay.example.com/#/pair?room=abc'); - expect(state.userId).assertEqual('harmony-device'); - expect(state.authenticatedUserId).assertEqual('desktop-user'); - expect(state.statusText).assertEqual('Connected'); - expect(state.connectionState).assertEqual('connected'); - expect(state.connectionFailureKind).assertEqual(''); - expect(state.isBusy).assertTrue(); - expect(state.showRemoteUrlInput).assertTrue(); - expect(state.workspacePath).assertEqual('/workspace/BitFun'); - expect(state.workspaceBranch).assertEqual('main'); - expect(state.workspaceKind).assertEqual('normal'); - expect(state.assistantId).assertEqual('default'); - expect(state.sessions.length).assertEqual(4); - expect(state.hasMoreSessions).assertTrue(); - expect(visible.length).assertEqual(1); - expect(visible[0].id).assertEqual('session-1'); - }); - - it('clears loading errors on success and clears only list state on reset', 0, () => { - const state = new RemotePageState(); - state.setDesktopName('Developer Mac'); - state.setWorkspace('BitFun', '/workspace/BitFun', 'default'); - state.setQuery('canvas'); - state.setLoading(true); - state.setError('network failed'); - - expect(state.isLoadingSessions).assertFalse(); - expect(state.sessionErrorText).assertEqual('network failed'); - - state.setSessions([remoteSession('session-1', '/canvas')], false); - expect(state.sessionErrorText).assertEqual(''); - state.clear(); - - expect(state.sessions.length).assertEqual(0); - expect(state.hasMoreSessions).assertFalse(); - expect(state.isLoadingSessions).assertFalse(); - expect(state.sessionQuery).assertEqual('canvas'); - expect(state.desktopName).assertEqual('Developer Mac'); - expect(state.workspaceName).assertEqual('BitFun'); - }); - - it('owns active remote session, timeline projection, and model catalog', 0, () => { - const state = new RemotePageState(); - const userMessage = chatMessage('remote-user-1', 'user', 'Run tests'); - const activeTurn = activeChatMessage('remote-turn-1', 'Running cargo test'); - const timelineItems: ChatTimelineItem[] = [{ - id: 'active-remote-turn-1-0', - type: 'assistant_live_turn', - message: activeTurn, - isStreaming: true, - isFinalizing: false - }]; - const modelCatalog: RemoteModelCatalog = { - version: 2, - models: [{ - id: 'model-a', - name: 'Model A', - provider: 'local', - base_url: 'https://model.example.com', - model_name: 'model-a', - enabled: true, - capabilities: ['code'] - }], - default_models: { primary: 'model-a' }, - session_model_id: 'model-a' - }; - - state.setActiveSession({ - sessionId: 'remote-1', - title: 'Remote task', - workspacePath: '/workspace/BitFun', - agentType: 'code' - }); - state.setTimelineProjection([userMessage], [], activeTurn, false, timelineItems); - state.setModelCatalog(modelCatalog, 'model-a'); - timelineItems.length = 0; - - expect(state.activeSession.sessionId).assertEqual('remote-1'); - expect(state.activeSession.workspacePath).assertEqual('/workspace/BitFun'); - expect(state.timelineItems.length).assertEqual(1); - expect(state.hasRunningActiveTurn()).assertTrue(); - expect(state.modelCatalog.version).assertEqual(2); - expect(state.selectedModelId).assertEqual('model-a'); - }); - - it('copies nested remote session projection and replaces streaming turn snapshots', 0, () => { - const state = new RemotePageState(); - const session: SessionSummary = { - sessionId: 'remote-nested-1', - title: 'Remote original', - workspacePath: '/workspace/BitFun', - agentType: 'code', - initialTurnId: 'remote-turn-initial' - }; - const firstTurn = activeChatMessage('remote-stream-1', 'Run', 'active', 1); - const secondTurn = activeChatMessage('remote-stream-1', 'Run tests', 'active', 2); - const catalog = remoteModelCatalog('model-a'); - - state.setActiveSession(session); - session.sessionId = 'mutated-remote'; - session.workspacePath = '/mutated'; - - state.setTimelineProjection([], [], firstTurn, false, []); - firstTurn.text = 'mutated remote turn'; - state.setTimelineProjection([], [], secondTurn, false, []); - state.setModelCatalog(catalog, 'model-a'); - catalog.models.length = 0; - catalog.session_model_id = 'model-b'; - - expect(state.activeSession.sessionId).assertEqual('remote-nested-1'); - expect(state.activeSession.workspacePath).assertEqual('/workspace/BitFun'); - expect(state.activeSession.agentType).assertEqual('code'); - expect(state.activeTurnMessage.text).assertEqual('Run tests'); - expect(state.activeTurnMessage.renderVersion || 0).assertEqual(2); - expect(state.modelCatalog.models.length).assertEqual(2); - expect(state.modelCatalog.session_model_id || '').assertEqual('model-a'); - expect(state.selectedModelId).assertEqual('model-a'); - expect(state.hasRunningActiveTurn()).assertTrue(); - }); - - it('owns remote download status projection', 0, () => { - const state = new RemotePageState(); - - state.setDownloadStatus('/workspace/BitFun/README.md', '', '10 B / 20 B'); - expect(state.downloadingFilePath).assertEqual('/workspace/BitFun/README.md'); - expect(state.downloadedFilePath).assertEqual(''); - expect(state.fileDownloadStatus).assertEqual('10 B / 20 B'); - - state.setDownloadStatus('/workspace/BitFun/README.md', '/workspace/BitFun/README.md', '下载完成 README.md · 20 B'); - state.clearDownloadingFilePath(); - expect(state.downloadingFilePath).assertEqual(''); - expect(state.downloadedFilePath).assertEqual('/workspace/BitFun/README.md'); - expect(state.fileDownloadStatus).assertEqual('下载完成 README.md · 20 B'); - - state.clearDownloadStatus(); - expect(state.downloadingFilePath).assertEqual(''); - expect(state.downloadedFilePath).assertEqual(''); - expect(state.fileDownloadStatus).assertEqual(''); - }); - - it('owns remote composer attachments and voice projection', 0, () => { - const state = new RemotePageState(); - const first = selectedImage('remote-image-1'); - const second = selectedImage('remote-image-2'); - const images = [first]; - - state.setChatInput('/canvas draw'); - state.setSelectedImages(images); - state.addSelectedImages([second]); - state.setVoiceListening(true); - images.length = 0; - - expect(state.chatInput).assertEqual('/canvas draw'); - expect(state.selectedImages.length).assertEqual(2); - expect(state.selectedImages[1].id).assertEqual('remote-image-2'); - expect(state.isVoiceListening).assertTrue(); - - state.removeSelectedImage('remote-image-1'); - expect(state.selectedImages.length).assertEqual(1); - expect(state.selectedImages[0].id).assertEqual('remote-image-2'); - - state.clearComposer(); - state.setVoiceListening(false); - expect(state.chatInput).assertEqual(''); - expect(state.selectedImages.length).assertEqual(0); - expect(state.isVoiceListening).assertFalse(); - }); - - it('owns copied workspace and assistant picker projections', 0, () => { - const state = new RemotePageState(); - const workspaces: RecentWorkspaceEntry[] = [{ - path: '/workspace/BitFun', - name: 'BitFun', - lastOpened: '2026-07-21T00:00:00.000Z', - workspaceKind: 'normal' - }]; - const assistants: AssistantEntry[] = [{ - path: '/workspace/BitFun/.bitfun/assistants/default.md', - name: 'Default', - assistant_id: 'default' - }]; - - state.setRecentWorkspaces(workspaces); - state.setAssistants(assistants); - state.setWorkspacePickerVisible(true); - state.setAssistantPickerVisible(true); - workspaces.length = 0; - assistants.length = 0; - - expect(state.recentWorkspaces.length).assertEqual(1); - expect(state.assistants.length).assertEqual(1); - expect(state.showWorkspacePicker).assertTrue(); - expect(state.showAssistantPicker).assertTrue(); - - state.clearWorkspaceActions(); - expect(state.recentWorkspaces.length).assertEqual(0); - expect(state.assistants.length).assertEqual(0); - expect(state.showWorkspacePicker).assertFalse(); - expect(state.showAssistantPicker).assertFalse(); - }); - }); - - describe('ProductSurfaceIsolation', () => { - it('keeps ordinary chat and remote chat page state isolated', 0, () => { - const general = new GeneralChatPageState(); - const remote = new RemotePageState(); - const generalMessage = chatMessage('general-user-1', 'user', 'Local chat only'); - const remoteMessage = chatMessage('remote-user-1', 'user', 'Remote task only'); - - general.setActiveSession({ - sessionId: 'general-1', - title: 'General chat', - workspacePath: '/should-not-leak', - agentType: 'code' - }); - remote.setActiveSession({ - sessionId: 'remote-1', - title: 'Remote chat', - workspacePath: '/workspace/BitFun', - agentType: 'code' - }); - general.setTimelineProjection([generalMessage], [], RemoteUiState.emptyActiveTurn(), false, [{ - id: 'general-item-1', - type: 'user_message', - message: generalMessage, - isStreaming: false, - isFinalizing: false - }]); - remote.setTimelineProjection([remoteMessage], [], RemoteUiState.emptyActiveTurn(), false, [{ - id: 'remote-item-1', - type: 'user_message', - message: remoteMessage, - isStreaming: false, - isFinalizing: false - }]); - - expect(general.activeSession.agentType).assertEqual('chat'); - expect(remote.activeSession.agentType).assertEqual('code'); - expect(general.persistedMessages[0].id).assertEqual('general-user-1'); - expect(remote.persistedMessages[0].id).assertEqual('remote-user-1'); - - general.clearActiveSession(); - - expect(general.activeSession.sessionId).assertEqual(''); - expect(general.persistedMessages.length).assertEqual(0); - expect(remote.activeSession.sessionId).assertEqual('remote-1'); - expect(remote.activeSession.workspacePath).assertEqual('/workspace/BitFun'); - expect(remote.persistedMessages.length).assertEqual(1); - }); - - it('keeps streaming turn projections isolated across product surfaces', 0, () => { - const general = new GeneralChatPageState(); - const remote = new RemotePageState(); - const generalTurn = activeChatMessage('general-live-1', 'General partial', 'active', 1); - const remoteTurn = activeChatMessage('remote-live-1', 'Remote partial', 'active', 1); - - general.setTimelineProjection([], [], generalTurn, false, [{ - id: 'general-live-item', - type: 'assistant_live_turn', - message: generalTurn, - isStreaming: true, - isFinalizing: false - }]); - remote.setTimelineProjection([], [], remoteTurn, false, [{ - id: 'remote-live-item', - type: 'assistant_live_turn', - message: remoteTurn, - isStreaming: true, - isFinalizing: false - }]); - - expect(general.activeTurnMessage.id).assertEqual('active-general-live-1'); - expect(remote.activeTurnMessage.id).assertEqual('active-remote-live-1'); - expect(general.timelineItems[0].id).assertEqual('general-live-item'); - expect(remote.timelineItems[0].id).assertEqual('remote-live-item'); - - general.clearTimeline(); - - expect(general.hasRunningActiveTurn()).assertFalse(); - expect(general.timelineItems.length).assertEqual(0); - expect(remote.hasRunningActiveTurn()).assertTrue(); - expect(remote.activeTurnMessage.text).assertEqual('Remote partial'); - expect(remote.timelineItems.length).assertEqual(1); - }); - }); - - describe('AppShellState', () => { - it('owns global sidebar and sheet visibility', 0, () => { - const state = new AppShellState(); - - state.setSidebarVisible(true); - state.setSettingsVisible(true); - state.setConnectSheetVisible(true); - - expect(state.showSidebar).assertTrue(); - expect(state.showSettings).assertTrue(); - expect(state.showConnectSheet).assertTrue(); - - state.closeGlobalSurfaces(); - expect(state.showSidebar).assertFalse(); - expect(state.showSettings).assertFalse(); - expect(state.showConnectSheet).assertFalse(); - }); - }); - - describe('RemoteHeartbeatController', () => { - it('owns heartbeat timer restart, stop, and tick dispatch', 0, () => { - const scheduler = new FakeRemoteHeartbeatScheduler(); - let tickCount = 0; - const controller = new RemoteHeartbeatController(() => { - tickCount += 1; - }, 15000, scheduler); - - controller.start(); - expect(controller.isRunning()).assertTrue(); - expect(scheduler.intervals.get(1)).assertEqual(15000); - scheduler.tick(1); - expect(tickCount).assertEqual(1); - - controller.start(); - expect(scheduler.clearedIds.length).assertEqual(1); - expect(scheduler.clearedIds[0]).assertEqual(1); - expect(scheduler.callbacks.has(1)).assertFalse(); - expect(scheduler.callbacks.has(2)).assertTrue(); - - controller.stop(); - controller.stop(); - expect(controller.isRunning()).assertFalse(); - expect(scheduler.clearedIds.length).assertEqual(2); - expect(scheduler.clearedIds[1]).assertEqual(2); - }); - }); - - describe('RemoteActivityLifecycleController', () => { - it('owns remote heartbeat lifecycle while preserving tick dispatch', 0, () => { - const scheduler = new FakeRemoteHeartbeatScheduler(); - let tickCount = 0; - const controller = new RemoteActivityLifecycleController(() => { - tickCount += 1; - }, 15000, scheduler); - - controller.startHeartbeat(); - expect(controller.isHeartbeatRunning()).assertTrue(); - expect(scheduler.intervals.get(1)).assertEqual(15000); - scheduler.tick(1); - expect(tickCount).assertEqual(1); - - controller.startHeartbeat(); - expect(scheduler.clearedIds[0]).assertEqual(1); - expect(scheduler.callbacks.has(1)).assertFalse(); - expect(scheduler.callbacks.has(2)).assertTrue(); - - controller.stopHeartbeat(); - controller.stopHeartbeat(); - expect(controller.isHeartbeatRunning()).assertFalse(); - expect(scheduler.clearedIds.length).assertEqual(2); - expect(scheduler.clearedIds[1]).assertEqual(2); - }); - }); - - describe('RemoteChatPollingLifecycleController', () => { - it('starts the active remote chat poller and forwards snapshots', 0, async () => { - const manager = new FakePollSessionManager(); - const snapshots: ChatSessionSnapshot[] = []; - manager.results = [pollResult({ - version: 5, - changed: true, - sessionState: 'idle', - title: 'Updated Session', - newMessages: [chatMessage('message-5', 'assistant', 'Done')], - totalMessageCount: 5 - })]; - const controller = new RemoteChatPollingLifecycleController(manager, { - onSnapshot: (snapshot: ChatSessionSnapshot) => { - snapshots.push(snapshot); - }, - onError: (_error: Object) => {}, - canPoll: (_sessionId: string) => true - }); - - controller.startActiveSession({ - sessionId: 'session-1', - cursor: { - pollVersion: 4, - knownMessageCount: 4, - knownModelCatalogVersion: 0 - } - }); - await delay(20); - controller.stop(); - - expect(snapshots.length).assertEqual(1); - expect(snapshots[0].sessionId).assertEqual('session-1'); - expect(snapshots[0].cursor.pollVersion).assertEqual(5); - expect(snapshots[0].cursor.knownMessageCount).assertEqual(5); - expect(snapshots[0].newMessages[0].id).assertEqual('message-5'); - }); - - it('ignores empty active session starts', 0, async () => { - const manager = new FakePollSessionManager(); - const snapshots: ChatSessionSnapshot[] = []; - manager.results = [pollResult({ - version: 1, - changed: false, - sessionState: 'idle', - title: 'Ignored', - newMessages: [], - totalMessageCount: 0 - })]; - const controller = new RemoteChatPollingLifecycleController(manager, { - onSnapshot: (snapshot: ChatSessionSnapshot) => { - snapshots.push(snapshot); - }, - onError: (_error: Object) => {}, - canPoll: (_sessionId: string) => true - }); - - controller.startActiveSession({ - sessionId: '', - cursor: { - pollVersion: 0, - knownMessageCount: 0, - knownModelCatalogVersion: 0 - } - }); - await delay(20); - - expect(snapshots.length).assertEqual(0); - expect(manager.results.length).assertEqual(1); - }); - }); - - describe('VoiceInputLifecycleController', () => { - it('owns start stop guard and callback routing for visible voice input', 0, async () => { - const service = new FakeVoiceInputLifecycleService(); - let inputText = ''; - let statusText = ''; - let clearAllCount = 0; - const inputEvents: string[] = []; - const listeningEvents: string[] = []; - const statuses: string[] = []; - const errors: string[] = []; - const callbacks: VoiceInputLifecycleCallbacks = { - currentInputText: (): string => inputText, - currentStatusText: (): string => statusText, - onInputText: (routeId: string, text: string): void => { - inputText = text; - inputEvents.push(`${routeId}:${text}`); - }, - onListening: (routeId: string, isListening: boolean): void => { - listeningEvents.push(`${routeId}:${isListening ? '1' : '0'}`); - }, - onStatusText: (nextStatusText: string): void => { - statusText = nextStatusText; - statuses.push(nextStatusText); - }, - onError: (message: string): void => { - statusText = message; - errors.push(message); - } - }; - const controller = new VoiceInputLifecycleController(service, callbacks); - const snapshot: VoiceInputRouteSnapshot = { - routeId: 'GeneralChat', - isListening: false, - isBusy: false, - inputText: '', - selectedImageCount: 0 - }; - - expect(await controller.toggle({} as Context, snapshot)).assertTrue(); - service.emitText('recognized text', true); - expect(service.startRequests).assertEqual(1); - expect(inputEvents[0]).assertEqual('GeneralChat:recognized text'); - expect(listeningEvents[0]).assertEqual('GeneralChat:1'); - expect(statuses.indexOf(RemoteI18n.t('status.voiceStarting')) >= 0).assertTrue(); - expect(statuses.indexOf(RemoteI18n.t('status.voiceListening')) >= 0).assertTrue(); - expect(statuses.indexOf(RemoteI18n.t('status.voiceRecognized')) >= 0).assertTrue(); - - expect(await controller.stop({ - routeId: 'GeneralChat', - isListening: true, - isBusy: false, - inputText, - selectedImageCount: 0 - }, true)).assertTrue(); - expect(service.stopRequests).assertEqual(1); - expect(listeningEvents[listeningEvents.length - 1]).assertEqual('GeneralChat:0'); - - expect(await controller.toggle({} as Context, { - routeId: 'GeneralChat', - isListening: false, - isBusy: true, - inputText: '', - selectedImageCount: 0 - })).assertFalse(); - expect(service.startRequests).assertEqual(1); - - service.shouldFailStart = true; - inputText = ''; - expect(await controller.toggle({} as Context, snapshot)).assertFalse(); - expect(errors[0]).assertEqual('Expected voice start failure.'); - - await controller.cancel('GeneralChat', () => { - clearAllCount += 1; - }); - expect(service.cancelRequests).assertEqual(1); - expect(clearAllCount).assertEqual(1); - }); - }); - - describe('GeneralChatDraftController', () => { - it('owns debounced draft save timer and immediate persistence paths', 0, async () => { - const store = new FakeGeneralChatLocalStore(); - const scheduler = new FakeGeneralChatDraftScheduler(); - const controller = new GeneralChatDraftController(store, 250, (_err: Error) => {}, scheduler); - - controller.scheduleSave('draft-1', 'first'); - expect(controller.isPending()).assertTrue(); - expect(scheduler.delays.get(1)).assertEqual(250); - scheduler.tick(1); - await Promise.resolve(); - expect(controller.isPending()).assertFalse(); - expect(await store.loadDraft('draft-1')).assertEqual('first'); - - controller.scheduleSave('draft-1', 'old'); - controller.scheduleSave('draft-1', 'new'); - expect(scheduler.clearedIds[0]).assertEqual(2); - scheduler.tick(2); - await Promise.resolve(); - expect(await store.loadDraft('draft-1')).assertEqual('first'); - scheduler.tick(3); - await Promise.resolve(); - expect(await store.loadDraft('draft-1')).assertEqual('new'); - - controller.scheduleSave('draft-1', 'pending'); - controller.persistNow('draft-1', 'now'); - await Promise.resolve(); - expect(scheduler.clearedIds[1]).assertEqual(4); - expect(await store.loadDraft('draft-1')).assertEqual('now'); - - await controller.clearNow('draft-1'); - expect(await store.loadDraft('draft-1')).assertEqual(''); - await store.saveDraft('draft-1', 'restored'); - expect(await controller.restore('draft-1')).assertEqual('restored'); - }); - }); - - describe('GeneralChatDraftLifecycleController', () => { - it('routes home and visible draft lifecycle operations through the draft controller', 0, async () => { - const store = new FakeGeneralChatLocalStore(); - const scheduler = new FakeGeneralChatDraftScheduler(); - const draftController = new GeneralChatDraftController(store, 250, (_err: Error) => {}, scheduler); - let visibleDraftId = ''; - const lifecycle = new GeneralChatDraftLifecycleController( - draftController, - 'home-draft', - (): string => visibleDraftId - ); - - lifecycle.scheduleVisible('ignored'); - expect(draftController.isPending()).assertFalse(); - - visibleDraftId = 'chat-1'; - lifecycle.scheduleVisible('draft text'); - expect(draftController.isPending()).assertTrue(); - scheduler.tick(1); - await Promise.resolve(); - expect(await lifecycle.restore('chat-1')).assertEqual('draft text'); - - lifecycle.persistVisible('persisted text'); - await Promise.resolve(); - expect(await lifecycle.restore('chat-1')).assertEqual('persisted text'); - - await store.saveDraft('home-draft', 'home text'); - expect(await lifecycle.restoreHome()).assertEqual('home text'); - await lifecycle.clearHomeNow(); - expect(await lifecycle.restoreHome()).assertEqual(''); - - lifecycle.scheduleVisible('pending'); - lifecycle.cancel(); - expect(draftController.isPending()).assertFalse(); - }); - }); - - describe('GeneralChatBootstrapController', () => { - it('restores configuration sessions and home draft through lifecycle collaborators', 0, async () => { - const configStore = new FakeGeneralChatConfigStore(); - const client = new FakeGeneralChatCommandClient(); - const localStore = new FakeGeneralChatLocalStore(); - const draftController = new GeneralChatDraftController(localStore); - const draftLifecycle = new GeneralChatDraftLifecycleController( - draftController, - 'home-draft', - (): string => 'home-draft' - ); - const restoredConfigs: GeneralChatConfigSnapshot[] = []; - const restoredDrafts: string[] = []; - const statuses: string[] = []; - await localStore.saveDraft('home-draft', 'saved home draft'); - const commandController = new GeneralChatCommandController(client, { - onSessions: (_sessions: RemoteSession[]) => {}, - onSessionPrepared: (_sessionId: string) => {}, - onActiveSession: (_session: SessionSummary) => {}, - onMessagesLoaded: (_messages: ChatMessage[]) => {}, - onClearComposer: () => {}, - onChatInput: (_text: string) => {}, - onStatusText: (_statusText: string) => {}, - onBusy: (_isBusy: boolean) => {}, - onToast: (_statusText: string) => {} - }); - const bootstrap = new GeneralChatBootstrapController( - configStore, - commandController, - draftLifecycle, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - restoredConfigs.push(snapshot); - }, - onHomeDraftRestored: (text: string) => { - restoredDrafts.push(text); - }, - onStatusText: (statusText: string) => { - statuses.push(statusText); - } - } - ); - - await bootstrap.restore({} as Context); - - expect(configStore.initRequests).assertEqual(1); - expect(client.initRequests).assertEqual(1); - expect(restoredConfigs[0].modelName).assertEqual('model-a'); - expect(restoredDrafts[0]).assertEqual('saved home draft'); - expect(statuses.length).assertEqual(0); - - client.shouldFail = true; - await bootstrap.restore({} as Context); - expect(statuses[0]).assertEqual(RemoteI18n.t('generalChat.localRestoreFailed')); - }); - }); - - describe('GeneralChatCommandController', () => { - it('opens and creates general chat sessions through page callbacks', 0, async () => { - const client = new FakeGeneralChatCommandClient(); - const activeSessions: SessionSummary[] = []; - const preparedSessions: string[] = []; - const openedRoutes: string[] = []; - const loadedMessages: ChatMessage[][] = []; - const chatInputs: string[] = []; - let clearComposerCount = 0; - const session = remoteSession('chat-1', 'Chat 1'); - session.agentType = 'chat'; - client.currentSessions = [session]; - client.messagesBySession.set('chat-1', [chatMessage('message-1', 'assistant', 'Welcome')]); - const controller = new GeneralChatCommandController(client, { - onSessions: (_items: RemoteSession[]) => {}, - onSessionPrepared: (sessionId: string) => { - preparedSessions.push(sessionId); - }, - onActiveSession: (activeSession: SessionSummary) => { - activeSessions.push(activeSession); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - loadedMessages.push(messages); - }, - onClearComposer: () => { - clearComposerCount += 1; - }, - onChatInput: (text: string) => { - chatInputs.push(text); - }, - onStatusText: (_statusText: string) => {}, - onBusy: (_isBusy: boolean) => {}, - onToast: (_statusText: string) => {} - }); - - const opened = await controller.openSession(session, false, async (_sessionId: string): Promise => { - return 'draft text'; - }, (sessionId: string): void => { - openedRoutes.push(`open:${sessionId}`); - }); - const created = await controller.createSession(' new prompt ', false, async (): Promise => { - openedRoutes.push('draft-cleared'); - }, (sessionId: string): void => { - openedRoutes.push(`create:${sessionId}`); - }); - - expect(opened).assertTrue(); - expect(created).assertTrue(); - expect(preparedSessions[0]).assertEqual('chat-1'); - expect(preparedSessions[1]).assertEqual('chat-created'); - expect(activeSessions[0].sessionId).assertEqual('chat-1'); - expect(activeSessions[1].sessionId).assertEqual('chat-created'); - expect(loadedMessages[0][0].id).assertEqual('message-1'); - expect(clearComposerCount).assertEqual(1); - expect(chatInputs[0]).assertEqual('draft text'); - expect(client.createRequests[0]).assertEqual('new prompt'); - expect(openedRoutes[0]).assertEqual('open:chat-1'); - expect(openedRoutes[1]).assertEqual('create:chat-created'); - expect(openedRoutes[2]).assertEqual('draft-cleared'); - }); - - it('handles delete archive and export session commands', 0, async () => { - const client = new FakeGeneralChatCommandClient(); - const sessions: RemoteSession[][] = []; - const statuses: string[] = []; - const busyEvents: boolean[] = []; - const toasts: string[] = []; - const exportedText: string[] = []; - const session = remoteSession('chat-1', 'Chat 1'); - session.agentType = 'chat'; - client.currentSessions = [session]; - client.messagesBySession.set('chat-1', [chatMessage('message-1', 'user', 'Hello')]); - const controller = new GeneralChatCommandController(client, { - onSessions: (items: RemoteSession[]) => { - sessions.push(items); - }, - onSessionPrepared: (_sessionId: string) => {}, - onActiveSession: (_activeSession: SessionSummary) => {}, - onMessagesLoaded: (_messages: ChatMessage[]) => {}, - onClearComposer: () => {}, - onChatInput: (_text: string) => {}, - onStatusText: (statusText: string) => { - statuses.push(statusText); - }, - onBusy: (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - onToast: (statusText: string) => { - toasts.push(statusText); - } - }); - - await controller.archiveSession(session, true, false); - await controller.exportSession(session, false, async (text: string): Promise => { - exportedText.push(text); - }); - await controller.deleteSession(session, false); - - expect(client.archiveRequests[0]).assertEqual('chat-1:1'); - expect(client.messageRequests[0]).assertEqual('chat-1'); - expect(exportedText[0].indexOf('Hello') >= 0).assertTrue(); - expect(client.deleteRequests[0]).assertEqual('chat-1'); - expect(sessions.length).assertEqual(2); - expect(sessions[sessions.length - 1].length).assertEqual(0); - expect(statuses.indexOf(RemoteI18n.t('status.sessionArchived')) >= 0).assertTrue(); - expect(statuses.indexOf(RemoteI18n.t('status.sessionExported')) >= 0).assertTrue(); - expect(statuses[statuses.length - 1]).assertEqual(RemoteI18n.t('status.sessionDeleted')); - expect(toasts.length).assertEqual(2); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[busyEvents.length - 1]).assertFalse(); - }); - - it('proxies send cancel and assistant persistence through the command boundary', 0, async () => { - const client = new FakeGeneralChatCommandClient(); - const sessions: RemoteSession[][] = []; - const userMessages: ChatMessage[] = []; - const deltas: string[] = []; - const controller = new GeneralChatCommandController(client, { - onSessions: (items: RemoteSession[]) => { - sessions.push(items); - }, - onSessionPrepared: (_sessionId: string) => {}, - onActiveSession: (_activeSession: SessionSummary) => {}, - onMessagesLoaded: (_messages: ChatMessage[]) => {}, - onClearComposer: () => {}, - onChatInput: (_text: string) => {}, - onStatusText: (_statusText: string) => {}, - onBusy: (_isBusy: boolean) => {}, - onToast: (_statusText: string) => {} - }); - - const result = await controller.sendMessage('chat-1', 'Hello', [selectedImage('image-1')], - new GeneralChatStreamCallbacks( - async (message: ChatMessage): Promise => { - userMessages.push(message); - }, - (delta: string): void => { - deltas.push(delta); - } - )); - const recorded = await controller.recordAssistantMessage('chat-1', result.assistantMessage); - controller.cancelCurrentRequest(); - - expect(client.sendRequests[0]).assertEqual('chat-1:Hello:1'); - expect(userMessages[0].id).assertEqual('sent-user'); - expect(deltas.join('')).assertEqual('Echo: Hello'); - expect(recorded).assertTrue(); - expect(client.recordedAssistantMessages[0]).assertEqual('chat-1:sent-assistant'); - expect(sessions.length).assertEqual(1); - expect(client.cancelCount).assertEqual(1); - }); - - it('renames active sessions and prepares retry message state', 0, async () => { - const client = new FakeGeneralChatCommandClient(); - const activeSessions: SessionSummary[] = []; - const loadedMessages: ChatMessage[][] = []; - const chatInputs: string[] = []; - const statuses: string[] = []; - const session = remoteSession('chat-1', 'Old title'); - session.agentType = 'chat'; - client.currentSessions = [session]; - client.messagesBySession.set('chat-1', [chatMessage('assistant-1', 'assistant', 'Recovered')]); - const controller = new GeneralChatCommandController(client, { - onSessions: (_items: RemoteSession[]) => {}, - onSessionPrepared: (_sessionId: string) => {}, - onActiveSession: (activeSession: SessionSummary) => { - activeSessions.push(activeSession); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - loadedMessages.push(messages); - }, - onClearComposer: () => {}, - onChatInput: (text: string) => { - chatInputs.push(text); - }, - onStatusText: (statusText: string) => { - statuses.push(statusText); - }, - onBusy: (_isBusy: boolean) => {}, - onToast: (_statusText: string) => {} - }); - - const renamed = await controller.renameActiveSession({ - sessionId: 'chat-1', - title: 'Old title', - workspacePath: '', - agentType: 'chat', - initialTurnId: 'turn-0' - }, ' New title '); - const prepared = await controller.retryMessage('chat-1', 'Retry text', false); - - expect(renamed).assertTrue(); - expect(client.renameRequests[0]).assertEqual('chat-1:New title'); - expect(activeSessions[0].title).assertEqual('New title'); - expect(activeSessions[0].initialTurnId || '').assertEqual('turn-0'); - expect(statuses[0]).assertEqual(RemoteI18n.t('status.titleUpdated')); - expect(prepared).assertTrue(); - expect(client.removeFailedRequests[0]).assertEqual('chat-1:Retry text'); - expect(loadedMessages[0][0].id).assertEqual('assistant-1'); - expect(chatInputs[0]).assertEqual('Retry text'); - }); - }); - - describe('RemoteFileDownloadController', () => { - it('owns remote file download progress and delayed downloading marker cleanup', 0, async () => { - const client = new FakeRemoteFileDownloadClient(); - const scheduler = new FakeRemoteFileDownloadScheduler(); - const status = new RemoteDownloadStatusRecord(); - const busyEvents: boolean[] = []; - let statusText = ''; - const controller = new RemoteFileDownloadController( - client, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - status.downloadingFilePath = downloadingFilePath; - status.downloadedFilePath = downloadedFilePath; - status.fileDownloadStatus = fileDownloadStatus; - }, - () => { - status.downloadingFilePath = ''; - }, - (nextStatusText: string) => { - statusText = nextStatusText; - }, - (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - 100, - scheduler - ); - - await controller.download('/workspace/BitFun/README.md', 'session-1', false, true); - - expect(client.infoRequests[0]).assertEqual('/workspace/BitFun/README.md|session-1'); - expect(client.readRequests[0]).assertEqual('/workspace/BitFun/README.md|session-1'); - expect(status.downloadedFilePath).assertEqual('/workspace/BitFun/README.md'); - expect(status.fileDownloadStatus.indexOf('README.md') >= 0).assertTrue(); - expect(statusText).assertEqual(status.fileDownloadStatus); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - expect(controller.isClearPending()).assertTrue(); - expect(scheduler.delays.get(1)).assertEqual(100); - - scheduler.tick(1); - expect(status.downloadingFilePath).assertEqual(''); - expect(status.downloadedFilePath).assertEqual('/workspace/BitFun/README.md'); - }); - - it('skips unavailable downloads before touching the client', 0, async () => { - const client = new FakeRemoteFileDownloadClient(); - const scheduler = new FakeRemoteFileDownloadScheduler(); - const status = new RemoteDownloadStatusRecord(); - const controller = new RemoteFileDownloadController( - client, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - status.downloadingFilePath = downloadingFilePath; - status.downloadedFilePath = downloadedFilePath; - status.fileDownloadStatus = fileDownloadStatus; - }, - () => { - status.downloadingFilePath = ''; - }, - (_statusText: string) => {}, - (_isBusy: boolean) => {}, - 100, - scheduler - ); - - await controller.download('', 'session-1', false, true); - await controller.download('/workspace/BitFun/README.md', '', false, true); - await controller.download('/workspace/BitFun/README.md', 'session-1', true, true); - await controller.download('/workspace/BitFun/README.md', 'session-1', false, false); - - expect(client.infoRequests.length).assertEqual(0); - expect(client.readRequests.length).assertEqual(0); - expect(status.fileDownloadStatus).assertEqual(''); - }); - }); - - describe('RemoteToolActionController', () => { - it('owns remote tool action status, busy, and poll callbacks', 0, async () => { - const client = new FakeRemoteToolActionClient(); - const busyEvents: boolean[] = []; - const statuses: string[] = []; - let pollCount = 0; - const controller = new RemoteToolActionController( - client, - (statusText: string) => { - statuses.push(statusText); - }, - (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - () => { - pollCount += 1; - } - ); - - const updatedInput: ToolInputFixture = { safe: true }; - await controller.approve('tool-1', 'session-1', false, true, updatedInput); - await controller.reject('tool-2', 'session-1', false, true); - await controller.cancel('tool-3', 'session-1', false, true); - await controller.answer('tool-4', 'session-1', false, true, { answer: 'yes', '0': 'yes' }); - - expect(client.actions[0]).assertEqual('confirm:tool-1:{"safe":true}'); - expect(client.actions[1]).assertEqual('reject:tool-2:Rejected from HarmonyOS remote client'); - expect(client.actions[2]).assertEqual('cancel:tool-3:Cancelled from HarmonyOS remote client'); - expect(client.actions[3].indexOf('answer:tool-4:') === 0).assertTrue(); - expect(client.actions[3].indexOf('"answer":"yes"') >= 0).assertTrue(); - expect(client.actions[3].indexOf('"0":"yes"') >= 0).assertTrue(); - expect(pollCount).assertEqual(4); - expect(busyEvents.length).assertEqual(8); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - expect(statuses.indexOf(RemoteI18n.t('status.approvingTool')) >= 0).assertTrue(); - expect(statuses.indexOf(RemoteI18n.t('status.toolApproved')) >= 0).assertTrue(); - }); - - it('skips tool actions when route state cannot run them', 0, async () => { - const client = new FakeRemoteToolActionClient(); - const busyEvents: boolean[] = []; - let pollCount = 0; - const controller = new RemoteToolActionController( - client, - (_statusText: string) => {}, - (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - () => { - pollCount += 1; - } - ); - - await controller.approve('', 'session-1', false, true); - await controller.approve('tool-1', '', false, true); - await controller.approve('tool-1', 'session-1', true, true); - await controller.approve('tool-1', 'session-1', false, false); - - expect(client.actions.length).assertEqual(0); - expect(busyEvents.length).assertEqual(0); - expect(pollCount).assertEqual(0); - }); - - it('reports failed tool actions and clears busy without polling', 0, async () => { - const client = new FakeRemoteToolActionClient(); - client.shouldFail = true; - const busyEvents: boolean[] = []; - const statuses: string[] = []; - let pollCount = 0; - const controller = new RemoteToolActionController( - client, - (statusText: string) => { - statuses.push(statusText); - }, - (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - () => { - pollCount += 1; - } - ); - - await controller.reject('tool-1', 'session-1', false, true); - - expect(client.actions.length).assertEqual(1); - expect(pollCount).assertEqual(0); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - expect(statuses[statuses.length - 1].indexOf('Expected tool action failure') >= 0).assertTrue(); - }); - }); - - describe('RemoteModelController', () => { - it('loads model catalog and applies the saved preferred model when still current', 0, async () => { - const client = new FakeRemoteModelClient(); - const preferences = new FakeRemoteModelPreferenceStore(); - const resets: RemoteModelProjectionRecord[] = []; - const applied: RemoteModelProjectionRecord[] = []; - const controller = new RemoteModelController( - client, - preferences, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - const record = new RemoteModelProjectionRecord(); - record.version = knownModelCatalogVersion; - record.selectedModelId = selectedModelId; - record.sessionModelId = modelCatalog.session_model_id || ''; - record.modelCount = modelCatalog.models.length; - resets.push(record); - }, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - const record = new RemoteModelProjectionRecord(); - record.version = knownModelCatalogVersion; - record.selectedModelId = selectedModelId; - record.sessionModelId = modelCatalog.session_model_id || ''; - record.modelCount = modelCatalog.models.length; - applied.push(record); - }, - (_statusText: string) => {}, - (_isBusy: boolean) => {} - ); - - controller.setPreferredModelId('model-b'); - await controller.loadCatalog('session-1', true, (sessionId: string) => sessionId === 'session-1'); - - expect(client.catalogRequests[0]).assertEqual('session-1'); - expect(resets.length).assertEqual(1); - expect(resets[0].modelCount).assertEqual(0); - expect(applied.length).assertEqual(1); - expect(applied[0].version).assertEqual(2); - expect(applied[0].selectedModelId).assertEqual('model-b'); - expect(applied[0].modelCount).assertEqual(2); - }); - - it('ignores stale catalog loads after clearing the visible projection', 0, async () => { - const client = new FakeRemoteModelClient(); - const preferences = new FakeRemoteModelPreferenceStore(); - let resetCount = 0; - let appliedCount = 0; - const controller = new RemoteModelController( - client, - preferences, - (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => { - resetCount += 1; - }, - (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => { - appliedCount += 1; - }, - (_statusText: string) => {}, - (_isBusy: boolean) => {} - ); - - await controller.loadCatalog('session-1', true, (_sessionId: string) => false); - - expect(client.catalogRequests.length).assertEqual(1); - expect(resetCount).assertEqual(1); - expect(appliedCount).assertEqual(0); - }); - - it('selects and persists a session model through the model client', 0, async () => { - const client = new FakeRemoteModelClient(); - const preferences = new FakeRemoteModelPreferenceStore(); - const statuses: string[] = []; - const busyEvents: boolean[] = []; - const applied: RemoteModelProjectionRecord[] = []; - const controller = new RemoteModelController( - client, - preferences, - (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => {}, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - const record = new RemoteModelProjectionRecord(); - record.version = knownModelCatalogVersion; - record.selectedModelId = selectedModelId; - record.sessionModelId = modelCatalog.session_model_id || ''; - record.modelCount = modelCatalog.models.length; - applied.push(record); - }, - (statusText: string) => { - statuses.push(statusText); - }, - (isBusy: boolean) => { - busyEvents.push(isBusy); - } - ); - - controller.applyCatalog(remoteModelCatalog()); - await controller.selectModel('model-b', 'session-1', false, true); - - expect(client.modelSelections[0]).assertEqual('session-1:model-b'); - expect(preferences.savedModelIds[0]).assertEqual('model-b'); - expect(applied[applied.length - 1].selectedModelId).assertEqual('model-b'); - expect(applied[applied.length - 1].sessionModelId).assertEqual('model-b'); - expect(statuses[0]).assertEqual(RemoteI18n.t('status.switchingModel')); - expect(statuses[1]).assertEqual(RemoteI18n.t('status.modelSwitched')); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - }); - - it('skips model selection guards and reports selection failures', 0, async () => { - const client = new FakeRemoteModelClient(); - const preferences = new FakeRemoteModelPreferenceStore(); - const statuses: string[] = []; - const busyEvents: boolean[] = []; - const controller = new RemoteModelController( - client, - preferences, - (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => {}, - (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => {}, - (statusText: string) => { - statuses.push(statusText); - }, - (isBusy: boolean) => { - busyEvents.push(isBusy); - } - ); - - await controller.selectModel('', 'session-1', false, true); - await controller.selectModel('model-a', '', false, true); - await controller.selectModel('model-a', 'session-1', true, true); - await controller.selectModel('model-a', 'session-1', false, false); - expect(client.modelSelections.length).assertEqual(0); - expect(busyEvents.length).assertEqual(0); - - client.shouldFailSelect = true; - await controller.selectModel('model-a', 'session-1', false, true); - expect(client.modelSelections.length).assertEqual(1); - expect(preferences.savedModelIds.length).assertEqual(0); - expect(statuses[statuses.length - 1].indexOf('Expected model selection failure') >= 0).assertTrue(); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - }); - }); - - describe('RemoteSessionController', () => { - it('refreshes session list and restores connected projection', 0, async () => { - const client = new FakeRemoteSessionClient(); - const projection = new RemoteSessionProjectionRecord(); - const statuses: string[] = []; - const loadingEvents: boolean[] = []; - let reconnectCount = 0; - let connectedCount = 0; - let heartbeatCount = 0; - client.listResults = [{ - sessions: [remoteSession('session-1', 'Session 1'), remoteSession('session-2', 'Session 2')], - hasMore: true - }]; - const controller = new RemoteSessionController(client, 8, { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - projection.sessions = sessions; - projection.hasMore = hasMore; - }, - onActiveSession: (_session: SessionSummary) => {}, - onStatusText: (statusText: string) => { - statuses.push(statusText); - }, - onBusy: (_isBusy: boolean) => {}, - onLoading: (isLoading: boolean) => { - loadingEvents.push(isLoading); - }, - onSessionError: (_errorText: string) => {}, - onReconnecting: () => { - reconnectCount += 1; - }, - onConnected: () => { - connectedCount += 1; - }, - onConnectionFailed: (_err: Object) => {}, - onStartHeartbeat: () => { - heartbeatCount += 1; - } - }); - - await controller.refresh('repo', 'code', true, false); - - expect(client.listRequests[0]).assertEqual('8:0:repo:code'); - expect(projection.sessions.length).assertEqual(2); - expect(projection.hasMore).assertTrue(); - expect(loadingEvents[0]).assertTrue(); - expect(loadingEvents[1]).assertFalse(); - expect(reconnectCount).assertEqual(1); - expect(connectedCount).assertEqual(1); - expect(heartbeatCount).assertEqual(1); - expect(statuses[0]).assertEqual(RemoteI18n.t('status.refreshSessions')); - expect(statuses[statuses.length - 1]).assertEqual(RemoteI18n.t('connection.connected')); - }); - - it('loads more sessions from current offset and keeps existing duplicates stable', 0, async () => { - const client = new FakeRemoteSessionClient(); - const projection = new RemoteSessionProjectionRecord(); - const busyEvents: boolean[] = []; - client.listResults = [{ - sessions: [remoteSession('session-1', 'Updated 1'), remoteSession('session-2', 'Session 2')], - hasMore: false - }]; - const controller = new RemoteSessionController(client, 2, { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - projection.sessions = sessions; - projection.hasMore = hasMore; - }, - onActiveSession: (_session: SessionSummary) => {}, - onStatusText: (_statusText: string) => {}, - onBusy: (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - onLoading: (_isLoading: boolean) => {}, - onSessionError: (_errorText: string) => {}, - onReconnecting: () => {}, - onConnected: () => {}, - onConnectionFailed: (_err: Object) => {}, - onStartHeartbeat: () => {} - }); - controller.setSessions([remoteSession('session-1', 'Session 1')], true); - - await controller.loadMore('', 'all', false, true); - - expect(client.listRequests[0]).assertEqual('2:1::all'); - expect(projection.sessions.length).assertEqual(2); - expect(projection.sessions[0].title).assertEqual('Session 1'); - expect(projection.sessions[1].id).assertEqual('session-2'); - expect(projection.hasMore).assertFalse(); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - }); - - it('creates and opens sessions through active-session callbacks', 0, async () => { - const client = new FakeRemoteSessionClient(); - const activeSessions: SessionSummary[] = []; - const openedSessions: SessionSummary[] = []; - const controller = new RemoteSessionController(client, 8, { - onSessions: (_sessions: RemoteSession[], _hasMore: boolean) => {}, - onActiveSession: (session: SessionSummary) => { - activeSessions.push(session); - }, - onStatusText: (_statusText: string) => {}, - onBusy: (_isBusy: boolean) => {}, - onLoading: (_isLoading: boolean) => {}, - onSessionError: (_errorText: string) => {}, - onReconnecting: () => {}, - onConnected: () => {}, - onConnectionFailed: (_err: Object) => {}, - onStartHeartbeat: () => {} - }); - - await controller.create('code', false, true, async (session: SessionSummary): Promise => { - openedSessions.push(session); - }); - await controller.open(remoteSession('opened-session', 'Opened Session'), '/workspace', false, true, - async (session: SessionSummary): Promise => { - openedSessions.push(session); - }); - - expect(client.createRequests[0]).assertEqual('code::'); - expect(activeSessions.length).assertEqual(2); - expect(activeSessions[0].sessionId).assertEqual('created-session'); - expect(activeSessions[1].sessionId).assertEqual('opened-session'); - expect(activeSessions[1].workspacePath).assertEqual('/workspace'); - expect(openedSessions.length).assertEqual(2); - }); - - it('deletes active sessions and backfills list when more pages exist', 0, async () => { - const client = new FakeRemoteSessionClient(); - const projection = new RemoteSessionProjectionRecord(); - let deletedActiveCount = 0; - client.listResults = [{ - sessions: [remoteSession('session-3', 'Session 3')], - hasMore: false - }]; - const controller = new RemoteSessionController(client, 2, { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - projection.sessions = sessions; - projection.hasMore = hasMore; - }, - onActiveSession: (_session: SessionSummary) => {}, - onStatusText: (_statusText: string) => {}, - onBusy: (_isBusy: boolean) => {}, - onLoading: (_isLoading: boolean) => {}, - onSessionError: (_errorText: string) => {}, - onReconnecting: () => {}, - onConnected: () => {}, - onConnectionFailed: (_err: Object) => {}, - onStartHeartbeat: () => {} - }); - controller.setSessions([ - remoteSession('session-1', 'Session 1'), - remoteSession('session-2', 'Session 2') - ], true); - - await controller.delete( - remoteSession('session-1', 'Session 1'), - 'session-1', - false, - true, - '', - 'all', - async (): Promise => { - deletedActiveCount += 1; - } - ); - - expect(client.deleteRequests[0]).assertEqual('session-1'); - expect(client.listRequests[0]).assertEqual('2:1::all'); - expect(deletedActiveCount).assertEqual(1); - expect(projection.sessions.length).assertEqual(2); - expect(projection.sessions[0].id).assertEqual('session-2'); - expect(projection.sessions[1].id).assertEqual('session-3'); - expect(projection.hasMore).assertFalse(); - }); - }); - - describe('RemoteChatCommandController', () => { - it('loads active messages and ignores stale route projections', 0, async () => { - const client = new FakeRemoteChatCommandClient(); - const projection = new RemoteChatMessagesProjectionRecord(); - const statuses: string[] = []; - client.messageResults = [{ - messages: [chatMessage('message-1', 'assistant', 'Hello')], - hasMore: false - }, { - messages: [chatMessage('message-2', 'assistant', 'Stale')], - hasMore: false - }]; - const controller = new RemoteChatCommandController(client, { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - projection.messages = messages; - projection.hasMoreMessages = hasMoreMessages; - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - projection.pollVersion = pollVersion; - projection.knownMessageCount = knownMessageCount; - }, - onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, - onSendFailed: ( - _rawText: string, - _images: SelectedImageAttachment[], - _localMessageId: string, - _pendingActiveId: string - ) => {}, - onActiveSession: (_session: SessionSummary) => {}, - onSessionTitleChanged: (_sessionId: string, _title: string) => {}, - onStatusText: (statusText: string) => { - statuses.push(statusText); - }, - onBusy: (_isBusy: boolean) => {}, - onPollRequested: () => {} - }); - - await controller.loadMessages('session-1', (_sessionId: string) => true); - await controller.loadMessages('session-2', (_sessionId: string) => false); - - expect(client.messageRequests[0]).assertEqual('session-1'); - expect(client.messageRequests[1]).assertEqual('session-2'); - expect(projection.messages.length).assertEqual(1); - expect(projection.messages[0].id).assertEqual('message-1'); - expect(projection.hasMoreMessages).assertFalse(); - expect(projection.pollVersion).assertEqual(0); - expect(projection.knownMessageCount).assertEqual(1); - expect(statuses[0]).assertEqual(RemoteI18n.t('status.loadMessages')); - expect(statuses[1]).assertEqual(RemoteI18n.t('status.messagesSynced')); - }); - - it('loads older messages with busy and cursor callbacks', 0, async () => { - const client = new FakeRemoteChatCommandClient(); - const projection = new RemoteChatMessagesProjectionRecord(); - const busyEvents: boolean[] = []; - client.messageResults = [{ - messages: [ - chatMessage('message-1', 'user', 'Older'), - chatMessage('message-2', 'assistant', 'Reply') - ], - hasMore: false - }]; - const controller = new RemoteChatCommandController(client, { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - projection.messages = messages; - projection.hasMoreMessages = hasMoreMessages; - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - projection.pollVersion = pollVersion; - projection.knownMessageCount = knownMessageCount; - }, - onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, - onSendFailed: ( - _rawText: string, - _images: SelectedImageAttachment[], - _localMessageId: string, - _pendingActiveId: string - ) => {}, - onActiveSession: (_session: SessionSummary) => {}, - onSessionTitleChanged: (_sessionId: string, _title: string) => {}, - onStatusText: (_statusText: string) => {}, - onBusy: (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - onPollRequested: () => {} - }); - - await controller.loadOlderMessages('session-1', 7, true, false); - - expect(client.messageRequests[0]).assertEqual('session-1'); - expect(projection.messages.length).assertEqual(2); - expect(projection.hasMoreMessages).assertFalse(); - expect(projection.pollVersion).assertEqual(7); - expect(projection.knownMessageCount).assertEqual(2); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - }); - - it('sends prepared remote messages and reports failed payloads', 0, async () => { - const client = new FakeRemoteChatCommandClient(); - const succeeded: string[] = []; - const failed: string[] = []; - const busyEvents: boolean[] = []; - const controller = new RemoteChatCommandController(client, { - onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, - onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, - onSendSucceeded: (turnId: string, pendingActiveId: string) => { - succeeded.push(`${turnId}:${pendingActiveId}`); - }, - onSendFailed: ( - rawText: string, - images: SelectedImageAttachment[], - localMessageId: string, - pendingActiveId: string - ) => { - failed.push(`${rawText}:${images.length}:${localMessageId}:${pendingActiveId}`); - }, - onActiveSession: (_session: SessionSummary) => {}, - onSessionTitleChanged: (_sessionId: string, _title: string) => {}, - onStatusText: (_statusText: string) => {}, - onBusy: (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - onPollRequested: () => {} - }); - - await controller.sendPreparedMessage( - 'session-1', - 'Describe image', - 'code', - 'Describe image', - [selectedImage('image-1')], - [{ - id: 'image-1', - data_url: 'data:image/png;base64,AA==', - mime_type: 'image/png' - }], - 'local-1', - 'pending-1', - false, - true - ); - client.shouldFailSend = true; - await controller.sendPreparedMessage( - 'session-1', - 'Retry text', - 'code', - 'Retry text', - [], - [], - 'local-2', - 'pending-2', - false, - true - ); - - expect(client.sendRequests[0]).assertEqual('images:session-1:Describe image:code:1'); - expect(client.sendRequests[1]).assertEqual('text:session-1:Retry text:code'); - expect(succeeded[0]).assertEqual('turn-1:pending-1'); - expect(failed[0]).assertEqual('Retry text:0:local-2:pending-2'); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - expect(busyEvents[2]).assertTrue(); - expect(busyEvents[3]).assertFalse(); - }); - - it('stops active tasks and reports guard states', 0, async () => { - const client = new FakeRemoteChatCommandClient(); - const statuses: string[] = []; - let pollCount = 0; - const controller = new RemoteChatCommandController(client, { - onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, - onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, - onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, - onSendFailed: ( - _rawText: string, - _images: SelectedImageAttachment[], - _localMessageId: string, - _pendingActiveId: string - ) => {}, - onActiveSession: (_session: SessionSummary) => {}, - onSessionTitleChanged: (_sessionId: string, _title: string) => {}, - onStatusText: (statusText: string) => { - statuses.push(statusText); - }, - onBusy: (_isBusy: boolean) => {}, - onPollRequested: () => { - pollCount += 1; - } - }); - - await controller.stopTask('session-1', '', '', true); - await controller.stopTask('session-1', 'active-turn', 'turn-1', true); - - expect(client.cancelRequests[0]).assertEqual('session-1:turn-1'); - expect(statuses[0]).assertEqual(RemoteI18n.t('status.noRunningTask')); - expect(statuses[1]).assertEqual(RemoteI18n.t('status.stoppingTask')); - expect(statuses[2]).assertEqual(RemoteI18n.t('status.stopRequested')); - expect(pollCount).assertEqual(1); - }); - - it('renames active sessions and updates active/list projections', 0, async () => { - const client = new FakeRemoteChatCommandClient(); - const activeSessions: SessionSummary[] = []; - const titleUpdates: string[] = []; - const busyEvents: boolean[] = []; - const controller = new RemoteChatCommandController(client, { - onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, - onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, - onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, - onSendFailed: ( - _rawText: string, - _images: SelectedImageAttachment[], - _localMessageId: string, - _pendingActiveId: string - ) => {}, - onActiveSession: (session: SessionSummary) => { - activeSessions.push(session); - }, - onSessionTitleChanged: (sessionId: string, title: string) => { - titleUpdates.push(`${sessionId}:${title}`); - }, - onStatusText: (_statusText: string) => {}, - onBusy: (isBusy: boolean) => { - busyEvents.push(isBusy); - }, - onPollRequested: () => {} - }); - - await controller.renameActiveSession({ - sessionId: 'session-1', - title: 'Old title', - workspacePath: '/workspace', - agentType: 'code', - initialTurnId: 'turn-0' - }, ' New title ', false, true); - - expect(client.renameRequests[0]).assertEqual('session-1:New title'); - expect(activeSessions[0].title).assertEqual('New title'); - expect(activeSessions[0].initialTurnId || '').assertEqual('turn-0'); - expect(titleUpdates[0]).assertEqual('session-1:New title'); - expect(busyEvents[0]).assertTrue(); - expect(busyEvents[1]).assertFalse(); - }); - }); - - describe('ChatComposerCapabilities', () => { - it('keeps route-to-surface composer ownership explicit', 0, () => { - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertTrue(); - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.GeneralChat)).assertTrue(); - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteHome)).assertFalse(); - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteChat)).assertFalse(); - }); - - it('keeps general chat independent from remote-only composer requirements', 0, () => { - expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.General); - expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertFalse(); - expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertFalse(); - }); - - it('keeps remote composer attachments behind the remote connection', 0, () => { - expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.Remote); - expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); - expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertTrue(); - }); - }); - - describe('ConversationViewContract', () => { - it('keeps empty ordinary chat on the unified conversation surface with suggestions', 0, () => { - const emptyItems = ChatTimelineProjector.project([], [], RemoteUiState.emptyActiveTurn(), false); - - expect(ConversationViewContract.surfaceForRoute(false)).assertEqual(ChatSurface.General); - expect(ConversationViewContract.shouldShowSuggestions(true, false, emptyItems)).assertTrue(); - expect(ConversationViewContract.visibleTimelineItems(emptyItems).length).assertEqual(0); - expect(ConversationViewContract.hasRealTimelineItem(emptyItems)).assertFalse(); - }); - - it('removes suggestions on the same page once ordinary chat has real content or is sending', 0, () => { - const emptyItems = ChatTimelineProjector.project([], [], RemoteUiState.emptyActiveTurn(), false); - const messageItems = ChatTimelineProjector.project([ - chatMessage('general-user-1', 'user', 'Write a short intro') - ], [], RemoteUiState.emptyActiveTurn(), false); - - expect(ConversationViewContract.shouldShowSuggestions(true, true, emptyItems)).assertFalse(); - expect(ConversationViewContract.shouldShowSuggestions(true, false, messageItems)).assertFalse(); - expect(ConversationViewContract.visibleTimelineItems(messageItems).length).assertEqual(1); - expect(ConversationViewContract.visibleTimelineItems(messageItems)[0].type).assertEqual('user_message'); - expect(ConversationViewContract.hasRealTimelineItem(messageItems)).assertTrue(); - }); - - it('keeps remote empty conversations blank instead of rendering the old empty card', 0, () => { - const emptyItems = ChatTimelineProjector.project([], [], RemoteUiState.emptyActiveTurn(), false); - - expect(ConversationViewContract.surfaceForRoute(true)).assertEqual(ChatSurface.Remote); - expect(ConversationViewContract.shouldShowSuggestions(false, false, emptyItems)).assertFalse(); - expect(ConversationViewContract.visibleTimelineItems(emptyItems).length).assertEqual(0); - }); - }); - - describe('AppRouteContract', () => { - it('defines stable route ids and session-carrying route params', 0, () => { - const generalParam = AppRouteContract.routeParam('general-session-1'); - const remoteParam = AppRouteContract.routeParam('remote-session-1'); - - expect(`${AppRoute.ChatHome}`).assertEqual('ChatHome'); - expect(`${AppRoute.GeneralChat}`).assertEqual('GeneralChat'); - expect(`${AppRoute.RemoteHome}`).assertEqual('RemoteHome'); - expect(`${AppRoute.RemoteChat}`).assertEqual('RemoteChat'); - expect(AppRouteContract.isSessionRoute(AppRoute.ChatHome)).assertFalse(); - expect(AppRouteContract.isSessionRoute(AppRoute.RemoteHome)).assertFalse(); - expect(AppRouteContract.isSessionRoute(AppRoute.GeneralChat)).assertTrue(); - expect(AppRouteContract.isSessionRoute(AppRoute.RemoteChat)).assertTrue(); - expect(generalParam.sessionId).assertEqual('general-session-1'); - expect(remoteParam.sessionId).assertEqual('remote-session-1'); - }); - - it('derives current route from NavPathStack path names with ChatHome as root', 0, () => { - expect(AppRouteContract.currentRoute([])).assertEqual(AppRoute.ChatHome); - expect(AppRouteContract.currentRoute([AppRoute.RemoteHome])).assertEqual(AppRoute.RemoteHome); - expect(AppRouteContract.currentRoute([ - AppRoute.RemoteHome, - AppRoute.RemoteChat - ])).assertEqual(AppRoute.RemoteChat); - expect(AppRouteContract.currentRoute([ - AppRoute.GeneralChat - ])).assertEqual(AppRoute.GeneralChat); - }); - - it('builds push path specs and suppresses duplicate current routes', 0, () => { - const duplicate = AppRouteContract.pathSpec(AppRoute.RemoteHome, AppRoute.RemoteHome, ''); - const chatHomeDuplicate = AppRouteContract.pathSpec(AppRoute.ChatHome, AppRoute.ChatHome, 'general-session-1'); - const remoteChatSpec = AppRouteContract.pathSpec( - AppRoute.RemoteHome, - AppRoute.RemoteChat, - 'remote-session-1' - ) as AppNavigationPathSpec; - const generalChatSpec = AppRouteContract.pathSpec( - AppRoute.ChatHome, - AppRoute.GeneralChat, - 'general-session-1' - ) as AppNavigationPathSpec; - - expect(duplicate === undefined).assertTrue(); - expect(chatHomeDuplicate === undefined).assertTrue(); - expect(remoteChatSpec.name).assertEqual(AppRoute.RemoteChat); - expect(remoteChatSpec.hasSessionParam()).assertTrue(); - expect(remoteChatSpec.routeParam().sessionId).assertEqual('remote-session-1'); - expect(generalChatSpec.name).assertEqual(AppRoute.GeneralChat); - expect(generalChatSpec.routeParam().sessionId).assertEqual('general-session-1'); - }); - - it('classifies back actions without depending on ArkUI NavPathStack', 0, () => { - expect(AppRouteContract.backAction(AppRoute.ChatHome, true)) - .assertEqual(AppNavigationBackAction.CloseSidebar); - expect(AppRouteContract.backAction(AppRoute.GeneralChat, false)) - .assertEqual(AppNavigationBackAction.CloseActiveChat); - expect(AppRouteContract.backAction(AppRoute.RemoteChat, false)) - .assertEqual(AppNavigationBackAction.CloseActiveChat); - expect(AppRouteContract.backAction(AppRoute.RemoteHome, false)) - .assertEqual(AppNavigationBackAction.PopRemoteHome); - expect(AppRouteContract.backAction(AppRoute.ChatHome, false)) - .assertEqual(AppNavigationBackAction.AllowSystem); - }); - }); - - describe('ChatSessionController', () => { - it('preserves active turn when unchanged poll omits active turn', 0, async () => { - const manager = new FakePollSessionManager(); - const snapshots: ChatSessionSnapshot[] = []; - manager.results = [pollResult({ - version: 1, - changed: false, - sessionState: 'running', - title: 'Session', - newMessages: [], - totalMessageCount: 1 - })]; - const controller = new ChatSessionController(manager, { - onSnapshot: (snapshot: ChatSessionSnapshot) => { - snapshots.push(snapshot); - }, - onError: (_error: Object) => {}, - canPoll: (_sessionId: string) => true - }); - - controller.start('session-1', { - pollVersion: 1, - knownMessageCount: 1, - knownModelCatalogVersion: 0 - }, chatMessage('active-turn-1', 'assistant', 'Working', 'active')); - await delay(20); - controller.stop(false); - - expect(snapshots.length).assertEqual(1); - expect(snapshots[0].changed).assertEqual(false); - expect(snapshots[0].activeTurn ? snapshots[0].activeTurn.id : '').assertEqual('active-turn-1'); - }); - - it('keeps completed active turn until final assistant message arrives', 0, async () => { - const manager = new FakePollSessionManager(); - const snapshots: ChatSessionSnapshot[] = []; - const controller = new ChatSessionController(manager, { - onSnapshot: (snapshot: ChatSessionSnapshot) => { - snapshots.push(snapshot); - }, - onError: (_error: Object) => {}, - canPoll: (_sessionId: string) => true - }); - - manager.results = [pollResult({ - version: 2, - changed: true, - sessionState: 'idle', - title: 'Session', - newMessages: [], - totalMessageCount: 1, - activeTurn: chatMessage('active-turn-2', 'assistant', 'Final text', 'completed') - })]; - controller.start('session-1', { - pollVersion: 1, - knownMessageCount: 1, - knownModelCatalogVersion: 0 - }, chatMessage('active-turn-2', 'assistant', 'Final text', 'active')); - await delay(20); - - manager.results = [pollResult({ - version: 3, - changed: true, - sessionState: 'idle', - title: 'Session', - newMessages: [], - totalMessageCount: 1 - })]; - await controller.pollNow(); - - manager.results = [pollResult({ - version: 4, - changed: true, - sessionState: 'idle', - title: 'Session', - newMessages: [ - chatMessage('assistant-final-1', 'assistant', 'Final text') - ], - totalMessageCount: 2 - })]; - await controller.pollNow(); - controller.stop(false); - - expect(snapshots.length).assertEqual(3); - expect(snapshots[0].activeTurn ? snapshots[0].activeTurn.status : '').assertEqual('completed'); - expect(snapshots[1].activeTurn ? snapshots[1].activeTurn.id : '').assertEqual('active-turn-2'); - expect(snapshots[2].activeTurn ? snapshots[2].activeTurn.id : '').assertEqual(''); - }); - - it('resyncs when a completed assistant message is updated during the settle window', 0, async () => { - const manager = new FakePollSessionManager(); - const snapshots: ChatSessionSnapshot[] = []; - const controller = new ChatSessionController(manager, { - onSnapshot: (snapshot: ChatSessionSnapshot) => { - snapshots.push(snapshot); - }, - onError: (_error: Object) => {}, - canPoll: (_sessionId: string) => true - }); - - manager.results = [pollResult({ - version: 2, - changed: true, - sessionState: 'idle', - title: 'Session', - newMessages: [ - chatMessage('turn-1-assistant', 'assistant', '') - ], - totalMessageCount: 2 - })]; - controller.start('session-1', { - pollVersion: 1, - knownMessageCount: 1, - knownModelCatalogVersion: 0 - }, chatMessage('active-turn-1', 'assistant', '', 'active')); - await delay(20); - - manager.results = [pollResult({ - version: 3, - changed: true, - sessionState: 'idle', - title: 'Session', - newMessages: [], - totalMessageCount: 2 - })]; - await controller.pollNow(); - controller.stop(false); - - expect(snapshots.length).assertEqual(2); - expect(snapshots[0].shouldSyncAfterTurnEnded).assertEqual(true); - expect(snapshots[1].newMessages.length).assertEqual(0); - expect(snapshots[1].shouldSyncAfterTurnEnded).assertEqual(true); - }); - }); + transportAndGeneralChatUnitTest(); + conversationStateUnitTest(); + lifecycleUnitTest(); + remoteControllersUnitTest(); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets new file mode 100644 index 0000000000..6b96e469e9 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -0,0 +1,1186 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; +import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; +import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; +import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; +import { ConversationEvent } from '../main/ets/services/ConversationEvent'; +import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; +import { Encoding } from '../main/ets/services/Encoding'; +import { + GeneralChatDraftStore, + GeneralChatLocalStore, + GeneralChatSendResult, + GeneralChatStreamCallbacks, + GeneralChatStreamObserver +} from '../main/ets/services/general-chat/GeneralChatPort'; +import { GeneralChatApiClient } from '../main/ets/services/general-chat/GeneralChatApiClient'; +import { GeneralChatBootstrapController } from '../main/ets/services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ets/services/general-chat/GeneralChatCommandController'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigValidator +} from '../main/ets/services/general-chat/GeneralChatConfigStore'; +import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; +import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; +import { + GeneralChatHttpHeaders, + GeneralChatHttpMethod, + GeneralChatHttpResponse, + GeneralChatHttpTransport, + GeneralChatTokenProvider +} from '../main/ets/services/general-chat/GeneralChatHttpTransport'; +import { GeneralChatRepository } from '../main/ets/services/general-chat/GeneralChatRepository'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../main/ets/services/general-chat/GeneralChatServiceState'; +import { GeneralChatStreamCoordinator } from '../main/ets/services/general-chat/GeneralChatStreamCoordinator'; +import { GeneralChatStreamLifecycleController } from '../main/ets/services/general-chat/GeneralChatStreamLifecycleController'; +import { + ModelProviderGeneralChatAdapter, + ModelProviderMessage, + ModelProviderProtocol +} from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; +import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; +import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; +import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; +import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; +import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; +import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; +import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler +} from '../main/ets/services/RemoteFileDownloadController'; +import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; +import { + RemoteModelClient, + RemoteModelController, + RemoteModelPreferenceStore +} from '../main/ets/services/RemoteModelController'; +import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; +import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; +import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; +import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; +import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; +import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; +import { RemoteUiState } from '../main/ets/services/RemoteUiState'; +import { + VoiceInputLifecycleCallbacks, + VoiceInputLifecycleController, + VoiceInputRouteSnapshot +} from '../main/ets/services/VoiceInputLifecycleController'; +import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; +import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES +} from '../main/ets/pages/components/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + AppNavigationBackAction, + AppNavigationPathSpec, + AppRoute, + AppRouteContract +} from '../main/ets/pages/navigation/AppRouteContract'; +import { TimeFormat } from '../main/ets/services/TimeFormat'; +import { X25519 } from '../main/ets/services/X25519'; +import { + ChatMessage, + FileInfo, + ImageAttachment, + PollSessionResult, + RecentWorkspaceEntry, + AssistantEntry, + CreateSessionOptions, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteModelCatalog, + ReadFileResult, + RemoteSession, + SelectedImageAttachment, + SessionListResult, + SessionMessagesResult, + SessionSummary, + WorkspaceInfo +} from '../main/ets/model/RemoteModels'; + +import { + ToolInputFixture, + CryptoImageFixture, + CryptoCommandFixture, + CryptoSimpleCommandFixture, + GeneralChatRecordedRequest, + FakeRemoteWorkspaceDataSource, + FakeRemoteCryptoCipher, + hexToBytes, + bytesToHex, + fixedRandomBytes, + expectCommandJson, + connectedPeers, + chatMessage, + remoteSession, + selectedImage, + remoteModelCatalog, + activeChatMessage, + chatMessageWithImage, + imageAttachment, + pollResult, + delay, + FakePollSessionManager, + BlockingPollSessionManager, + FakeGeneralChatLocalStore, + FailingGeneralChatAdapter, + PartialFailingGeneralChatAdapter, + FakeGeneralChatTokenProvider, + FakeGeneralChatHttpTransport, + FakeRemoteHeartbeatScheduler, + FakeGeneralChatDraftScheduler, + FakeVoiceInputLifecycleService, + FakeRemoteFileDownloadScheduler, + FakeRemoteFileDownloadClient, + RemoteDownloadStatusRecord, + FakeRemoteToolActionClient, + FakeRemoteModelClient, + FakeRemoteModelPreferenceStore, + RemoteModelProjectionRecord, + FakeRemoteSessionClient, + RemoteSessionProjectionRecord, + FakeRemoteChatCommandClient, + RemoteChatMessagesProjectionRecord, + FakeGeneralChatCommandClient, + FakeGeneralChatConfigStore +} from './LocalTestFixtures'; + +export default function remoteControllersUnitTest() { + describe('RemoteFileDownloadController', () => { + it('owns remote file download progress and delayed downloading marker cleanup', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + const scheduler = new FakeRemoteFileDownloadScheduler(); + const status = new RemoteDownloadStatusRecord(); + const busyEvents: boolean[] = []; + let statusText = ''; + const controller = new RemoteFileDownloadController( + client, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + status.downloadingFilePath = downloadingFilePath; + status.downloadedFilePath = downloadedFilePath; + status.fileDownloadStatus = fileDownloadStatus; + }, + () => { + status.downloadingFilePath = ''; + }, + (nextStatusText: string) => { + statusText = nextStatusText; + }, + (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + 100, + scheduler + ); + + await controller.download('/workspace/BitFun/README.md', 'session-1', false, true); + + expect(client.infoRequests[0]).assertEqual('/workspace/BitFun/README.md|session-1'); + expect(client.readRequests[0]).assertEqual('/workspace/BitFun/README.md|session-1'); + expect(status.downloadedFilePath).assertEqual('/workspace/BitFun/README.md'); + expect(status.fileDownloadStatus.indexOf('README.md') >= 0).assertTrue(); + expect(statusText).assertEqual(status.fileDownloadStatus); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + expect(controller.isClearPending()).assertTrue(); + expect(scheduler.delays.get(1)).assertEqual(100); + + scheduler.tick(1); + expect(status.downloadingFilePath).assertEqual(''); + expect(status.downloadedFilePath).assertEqual('/workspace/BitFun/README.md'); + }); + + it('skips unavailable downloads before touching the client', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + const scheduler = new FakeRemoteFileDownloadScheduler(); + const status = new RemoteDownloadStatusRecord(); + const controller = new RemoteFileDownloadController( + client, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + status.downloadingFilePath = downloadingFilePath; + status.downloadedFilePath = downloadedFilePath; + status.fileDownloadStatus = fileDownloadStatus; + }, + () => { + status.downloadingFilePath = ''; + }, + (_statusText: string) => {}, + (_isBusy: boolean) => {}, + 100, + scheduler + ); + + await controller.download('', 'session-1', false, true); + await controller.download('/workspace/BitFun/README.md', '', false, true); + await controller.download('/workspace/BitFun/README.md', 'session-1', true, true); + await controller.download('/workspace/BitFun/README.md', 'session-1', false, false); + + expect(client.infoRequests.length).assertEqual(0); + expect(client.readRequests.length).assertEqual(0); + expect(status.fileDownloadStatus).assertEqual(''); + }); + }); + + describe('RemoteToolActionController', () => { + it('owns remote tool action status, busy, and poll callbacks', 0, async () => { + const client = new FakeRemoteToolActionClient(); + const busyEvents: boolean[] = []; + const statuses: string[] = []; + let pollCount = 0; + const controller = new RemoteToolActionController( + client, + (statusText: string) => { + statuses.push(statusText); + }, + (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + () => { + pollCount += 1; + } + ); + + const updatedInput: ToolInputFixture = { safe: true }; + await controller.approve('tool-1', 'session-1', false, true, updatedInput); + await controller.reject('tool-2', 'session-1', false, true); + await controller.cancel('tool-3', 'session-1', false, true); + await controller.answer('tool-4', 'session-1', false, true, { answer: 'yes', '0': 'yes' }); + + expect(client.actions[0]).assertEqual('confirm:tool-1:{"safe":true}'); + expect(client.actions[1]).assertEqual('reject:tool-2:Rejected from HarmonyOS remote client'); + expect(client.actions[2]).assertEqual('cancel:tool-3:Cancelled from HarmonyOS remote client'); + expect(client.actions[3].indexOf('answer:tool-4:') === 0).assertTrue(); + expect(client.actions[3].indexOf('"answer":"yes"') >= 0).assertTrue(); + expect(client.actions[3].indexOf('"0":"yes"') >= 0).assertTrue(); + expect(pollCount).assertEqual(4); + expect(busyEvents.length).assertEqual(8); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + expect(statuses.indexOf(RemoteI18n.t('status.approvingTool')) >= 0).assertTrue(); + expect(statuses.indexOf(RemoteI18n.t('status.toolApproved')) >= 0).assertTrue(); + }); + + it('skips tool actions when route state cannot run them', 0, async () => { + const client = new FakeRemoteToolActionClient(); + const busyEvents: boolean[] = []; + let pollCount = 0; + const controller = new RemoteToolActionController( + client, + (_statusText: string) => {}, + (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + () => { + pollCount += 1; + } + ); + + await controller.approve('', 'session-1', false, true); + await controller.approve('tool-1', '', false, true); + await controller.approve('tool-1', 'session-1', true, true); + await controller.approve('tool-1', 'session-1', false, false); + + expect(client.actions.length).assertEqual(0); + expect(busyEvents.length).assertEqual(0); + expect(pollCount).assertEqual(0); + }); + + it('reports failed tool actions and clears busy without polling', 0, async () => { + const client = new FakeRemoteToolActionClient(); + client.shouldFail = true; + const busyEvents: boolean[] = []; + const statuses: string[] = []; + let pollCount = 0; + const controller = new RemoteToolActionController( + client, + (statusText: string) => { + statuses.push(statusText); + }, + (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + () => { + pollCount += 1; + } + ); + + await controller.reject('tool-1', 'session-1', false, true); + + expect(client.actions.length).assertEqual(1); + expect(pollCount).assertEqual(0); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + expect(statuses[statuses.length - 1].indexOf('Expected tool action failure') >= 0).assertTrue(); + }); + }); + + describe('RemoteModelController', () => { + it('loads model catalog and applies the saved preferred model when still current', 0, async () => { + const client = new FakeRemoteModelClient(); + const preferences = new FakeRemoteModelPreferenceStore(); + const resets: RemoteModelProjectionRecord[] = []; + const applied: RemoteModelProjectionRecord[] = []; + const controller = new RemoteModelController( + client, + preferences, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + const record = new RemoteModelProjectionRecord(); + record.version = knownModelCatalogVersion; + record.selectedModelId = selectedModelId; + record.sessionModelId = modelCatalog.session_model_id || ''; + record.modelCount = modelCatalog.models.length; + resets.push(record); + }, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + const record = new RemoteModelProjectionRecord(); + record.version = knownModelCatalogVersion; + record.selectedModelId = selectedModelId; + record.sessionModelId = modelCatalog.session_model_id || ''; + record.modelCount = modelCatalog.models.length; + applied.push(record); + }, + (_statusText: string) => {}, + (_isBusy: boolean) => {} + ); + + controller.setPreferredModelId('model-b'); + await controller.loadCatalog('session-1', true, (sessionId: string) => sessionId === 'session-1'); + + expect(client.catalogRequests[0]).assertEqual('session-1'); + expect(resets.length).assertEqual(1); + expect(resets[0].modelCount).assertEqual(0); + expect(applied.length).assertEqual(1); + expect(applied[0].version).assertEqual(2); + expect(applied[0].selectedModelId).assertEqual('model-b'); + expect(applied[0].modelCount).assertEqual(2); + }); + + it('ignores stale catalog loads after clearing the visible projection', 0, async () => { + const client = new FakeRemoteModelClient(); + const preferences = new FakeRemoteModelPreferenceStore(); + let resetCount = 0; + let appliedCount = 0; + const controller = new RemoteModelController( + client, + preferences, + (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => { + resetCount += 1; + }, + (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => { + appliedCount += 1; + }, + (_statusText: string) => {}, + (_isBusy: boolean) => {} + ); + + await controller.loadCatalog('session-1', true, (_sessionId: string) => false); + + expect(client.catalogRequests.length).assertEqual(1); + expect(resetCount).assertEqual(1); + expect(appliedCount).assertEqual(0); + }); + + it('selects and persists a session model through the model client', 0, async () => { + const client = new FakeRemoteModelClient(); + const preferences = new FakeRemoteModelPreferenceStore(); + const statuses: string[] = []; + const busyEvents: boolean[] = []; + const applied: RemoteModelProjectionRecord[] = []; + const controller = new RemoteModelController( + client, + preferences, + (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => {}, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + const record = new RemoteModelProjectionRecord(); + record.version = knownModelCatalogVersion; + record.selectedModelId = selectedModelId; + record.sessionModelId = modelCatalog.session_model_id || ''; + record.modelCount = modelCatalog.models.length; + applied.push(record); + }, + (statusText: string) => { + statuses.push(statusText); + }, + (isBusy: boolean) => { + busyEvents.push(isBusy); + } + ); + + controller.applyCatalog(remoteModelCatalog()); + await controller.selectModel('model-b', 'session-1', false, true); + + expect(client.modelSelections[0]).assertEqual('session-1:model-b'); + expect(preferences.savedModelIds[0]).assertEqual('model-b'); + expect(applied[applied.length - 1].selectedModelId).assertEqual('model-b'); + expect(applied[applied.length - 1].sessionModelId).assertEqual('model-b'); + expect(statuses[0]).assertEqual(RemoteI18n.t('status.switchingModel')); + expect(statuses[1]).assertEqual(RemoteI18n.t('status.modelSwitched')); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + }); + + it('skips model selection guards and reports selection failures', 0, async () => { + const client = new FakeRemoteModelClient(); + const preferences = new FakeRemoteModelPreferenceStore(); + const statuses: string[] = []; + const busyEvents: boolean[] = []; + const controller = new RemoteModelController( + client, + preferences, + (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => {}, + (_modelCatalog: RemoteModelCatalog, _selectedModelId: string, _knownModelCatalogVersion: number) => {}, + (statusText: string) => { + statuses.push(statusText); + }, + (isBusy: boolean) => { + busyEvents.push(isBusy); + } + ); + + await controller.selectModel('', 'session-1', false, true); + await controller.selectModel('model-a', '', false, true); + await controller.selectModel('model-a', 'session-1', true, true); + await controller.selectModel('model-a', 'session-1', false, false); + expect(client.modelSelections.length).assertEqual(0); + expect(busyEvents.length).assertEqual(0); + + client.shouldFailSelect = true; + await controller.selectModel('model-a', 'session-1', false, true); + expect(client.modelSelections.length).assertEqual(1); + expect(preferences.savedModelIds.length).assertEqual(0); + expect(statuses[statuses.length - 1].indexOf('Expected model selection failure') >= 0).assertTrue(); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + }); + }); + + describe('RemoteSessionController', () => { + it('refreshes session list and restores connected projection', 0, async () => { + const client = new FakeRemoteSessionClient(); + const projection = new RemoteSessionProjectionRecord(); + const statuses: string[] = []; + const loadingEvents: boolean[] = []; + let reconnectCount = 0; + let connectedCount = 0; + let heartbeatCount = 0; + client.listResults = [{ + sessions: [remoteSession('session-1', 'Session 1'), remoteSession('session-2', 'Session 2')], + hasMore: true + }]; + const controller = new RemoteSessionController(client, 8, { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + projection.sessions = sessions; + projection.hasMore = hasMore; + }, + onActiveSession: (_session: SessionSummary) => {}, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onBusy: (_isBusy: boolean) => {}, + onLoading: (isLoading: boolean) => { + loadingEvents.push(isLoading); + }, + onSessionError: (_errorText: string) => {}, + onReconnecting: () => { + reconnectCount += 1; + }, + onConnected: () => { + connectedCount += 1; + }, + onConnectionFailed: (_err: Object) => {}, + onStartHeartbeat: () => { + heartbeatCount += 1; + } + }); + + await controller.refresh('repo', 'code', true, false); + + expect(client.listRequests[0]).assertEqual('8:0:repo:code'); + expect(projection.sessions.length).assertEqual(2); + expect(projection.hasMore).assertTrue(); + expect(loadingEvents[0]).assertTrue(); + expect(loadingEvents[1]).assertFalse(); + expect(reconnectCount).assertEqual(1); + expect(connectedCount).assertEqual(1); + expect(heartbeatCount).assertEqual(1); + expect(statuses[0]).assertEqual(RemoteI18n.t('status.refreshSessions')); + expect(statuses[statuses.length - 1]).assertEqual(RemoteI18n.t('connection.connected')); + }); + + it('loads more sessions from current offset and keeps existing duplicates stable', 0, async () => { + const client = new FakeRemoteSessionClient(); + const projection = new RemoteSessionProjectionRecord(); + const busyEvents: boolean[] = []; + client.listResults = [{ + sessions: [remoteSession('session-1', 'Updated 1'), remoteSession('session-2', 'Session 2')], + hasMore: false + }]; + const controller = new RemoteSessionController(client, 2, { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + projection.sessions = sessions; + projection.hasMore = hasMore; + }, + onActiveSession: (_session: SessionSummary) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + onLoading: (_isLoading: boolean) => {}, + onSessionError: (_errorText: string) => {}, + onReconnecting: () => {}, + onConnected: () => {}, + onConnectionFailed: (_err: Object) => {}, + onStartHeartbeat: () => {} + }); + controller.setSessions([remoteSession('session-1', 'Session 1')], true); + + await controller.loadMore('', 'all', false, true); + + expect(client.listRequests[0]).assertEqual('2:1::all'); + expect(projection.sessions.length).assertEqual(2); + expect(projection.sessions[0].title).assertEqual('Session 1'); + expect(projection.sessions[1].id).assertEqual('session-2'); + expect(projection.hasMore).assertFalse(); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + }); + + it('creates and opens sessions through active-session callbacks', 0, async () => { + const client = new FakeRemoteSessionClient(); + const activeSessions: SessionSummary[] = []; + const openedSessions: SessionSummary[] = []; + const controller = new RemoteSessionController(client, 8, { + onSessions: (_sessions: RemoteSession[], _hasMore: boolean) => {}, + onActiveSession: (session: SessionSummary) => { + activeSessions.push(session); + }, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onLoading: (_isLoading: boolean) => {}, + onSessionError: (_errorText: string) => {}, + onReconnecting: () => {}, + onConnected: () => {}, + onConnectionFailed: (_err: Object) => {}, + onStartHeartbeat: () => {} + }); + + await controller.create('code', false, true, async (session: SessionSummary): Promise => { + openedSessions.push(session); + }); + await controller.open(remoteSession('opened-session', 'Opened Session'), '/workspace', false, true, + async (session: SessionSummary): Promise => { + openedSessions.push(session); + }); + + expect(client.createRequests[0]).assertEqual('code::'); + expect(activeSessions.length).assertEqual(2); + expect(activeSessions[0].sessionId).assertEqual('created-session'); + expect(activeSessions[1].sessionId).assertEqual('opened-session'); + expect(activeSessions[1].workspacePath).assertEqual('/workspace'); + expect(openedSessions.length).assertEqual(2); + }); + + it('deletes active sessions and backfills list when more pages exist', 0, async () => { + const client = new FakeRemoteSessionClient(); + const projection = new RemoteSessionProjectionRecord(); + let deletedActiveCount = 0; + client.listResults = [{ + sessions: [remoteSession('session-3', 'Session 3')], + hasMore: false + }]; + const controller = new RemoteSessionController(client, 2, { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + projection.sessions = sessions; + projection.hasMore = hasMore; + }, + onActiveSession: (_session: SessionSummary) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onLoading: (_isLoading: boolean) => {}, + onSessionError: (_errorText: string) => {}, + onReconnecting: () => {}, + onConnected: () => {}, + onConnectionFailed: (_err: Object) => {}, + onStartHeartbeat: () => {} + }); + controller.setSessions([ + remoteSession('session-1', 'Session 1'), + remoteSession('session-2', 'Session 2') + ], true); + + await controller.delete( + remoteSession('session-1', 'Session 1'), + 'session-1', + false, + true, + '', + 'all', + async (): Promise => { + deletedActiveCount += 1; + } + ); + + expect(client.deleteRequests[0]).assertEqual('session-1'); + expect(client.listRequests[0]).assertEqual('2:1::all'); + expect(deletedActiveCount).assertEqual(1); + expect(projection.sessions.length).assertEqual(2); + expect(projection.sessions[0].id).assertEqual('session-2'); + expect(projection.sessions[1].id).assertEqual('session-3'); + expect(projection.hasMore).assertFalse(); + }); + }); + + describe('RemoteChatCommandController', () => { + it('loads active messages and ignores stale route projections', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const projection = new RemoteChatMessagesProjectionRecord(); + const statuses: string[] = []; + client.messageResults = [{ + messages: [chatMessage('message-1', 'assistant', 'Hello')], + hasMore: false + }, { + messages: [chatMessage('message-2', 'assistant', 'Stale')], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + projection.messages = messages; + projection.hasMoreMessages = hasMoreMessages; + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + projection.pollVersion = pollVersion; + projection.knownMessageCount = knownMessageCount; + }, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }); + + await controller.loadMessages('session-1', (_sessionId: string) => true); + await controller.loadMessages('session-2', (_sessionId: string) => false); + + expect(client.messageRequests[0]).assertEqual('session-1'); + expect(client.messageRequests[1]).assertEqual('session-2'); + expect(projection.messages.length).assertEqual(1); + expect(projection.messages[0].id).assertEqual('message-1'); + expect(projection.hasMoreMessages).assertFalse(); + expect(projection.pollVersion).assertEqual(0); + expect(projection.knownMessageCount).assertEqual(1); + expect(statuses[0]).assertEqual(RemoteI18n.t('status.loadMessages')); + expect(statuses[1]).assertEqual(RemoteI18n.t('status.messagesSynced')); + }); + + it('loads older messages with busy and cursor callbacks', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const projection = new RemoteChatMessagesProjectionRecord(); + const busyEvents: boolean[] = []; + client.messageResults = [{ + messages: [ + chatMessage('message-1', 'user', 'Older'), + chatMessage('message-2', 'assistant', 'Reply') + ], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + projection.messages = messages; + projection.hasMoreMessages = hasMoreMessages; + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + projection.pollVersion = pollVersion; + projection.knownMessageCount = knownMessageCount; + }, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + onPollRequested: () => {} + }); + + await controller.loadOlderMessages('session-1', 7, true, false); + + expect(client.messageRequests[0]).assertEqual('session-1'); + expect(projection.messages.length).assertEqual(2); + expect(projection.hasMoreMessages).assertFalse(); + expect(projection.pollVersion).assertEqual(7); + expect(projection.knownMessageCount).assertEqual(2); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + }); + + it('sends prepared remote messages and reports failed payloads', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const succeeded: string[] = []; + const failed: string[] = []; + const busyEvents: boolean[] = []; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onSendSucceeded: (turnId: string, pendingActiveId: string) => { + succeeded.push(`${turnId}:${pendingActiveId}`); + }, + onSendFailed: ( + rawText: string, + images: SelectedImageAttachment[], + localMessageId: string, + pendingActiveId: string + ) => { + failed.push(`${rawText}:${images.length}:${localMessageId}:${pendingActiveId}`); + }, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + onPollRequested: () => {} + }); + + await controller.sendPreparedMessage( + 'session-1', + 'Describe image', + 'code', + 'Describe image', + [selectedImage('image-1')], + [{ + id: 'image-1', + data_url: 'data:image/png;base64,AA==', + mime_type: 'image/png' + }], + 'local-1', + 'pending-1', + false, + true + ); + client.shouldFailSend = true; + await controller.sendPreparedMessage( + 'session-1', + 'Retry text', + 'code', + 'Retry text', + [], + [], + 'local-2', + 'pending-2', + false, + true + ); + + expect(client.sendRequests[0]).assertEqual('images:session-1:Describe image:code:1'); + expect(client.sendRequests[1]).assertEqual('text:session-1:Retry text:code'); + expect(succeeded[0]).assertEqual('turn-1:pending-1'); + expect(failed[0]).assertEqual('Retry text:0:local-2:pending-2'); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + expect(busyEvents[2]).assertTrue(); + expect(busyEvents[3]).assertFalse(); + }); + + it('stops active tasks and reports guard states', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const statuses: string[] = []; + let pollCount = 0; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => { + pollCount += 1; + } + }); + + await controller.stopTask('session-1', '', '', true); + await controller.stopTask('session-1', 'active-turn', 'turn-1', true); + + expect(client.cancelRequests[0]).assertEqual('session-1:turn-1'); + expect(statuses[0]).assertEqual(RemoteI18n.t('status.noRunningTask')); + expect(statuses[1]).assertEqual(RemoteI18n.t('status.stoppingTask')); + expect(statuses[2]).assertEqual(RemoteI18n.t('status.stopRequested')); + expect(pollCount).assertEqual(1); + }); + + it('renames active sessions and updates active/list projections', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const activeSessions: SessionSummary[] = []; + const titleUpdates: string[] = []; + const busyEvents: boolean[] = []; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (session: SessionSummary) => { + activeSessions.push(session); + }, + onSessionTitleChanged: (sessionId: string, title: string) => { + titleUpdates.push(`${sessionId}:${title}`); + }, + onStatusText: (_statusText: string) => {}, + onBusy: (isBusy: boolean) => { + busyEvents.push(isBusy); + }, + onPollRequested: () => {} + }); + + await controller.renameActiveSession({ + sessionId: 'session-1', + title: 'Old title', + workspacePath: '/workspace', + agentType: 'code', + initialTurnId: 'turn-0' + }, ' New title ', false, true); + + expect(client.renameRequests[0]).assertEqual('session-1:New title'); + expect(activeSessions[0].title).assertEqual('New title'); + expect(activeSessions[0].initialTurnId || '').assertEqual('turn-0'); + expect(titleUpdates[0]).assertEqual('session-1:New title'); + expect(busyEvents[0]).assertTrue(); + expect(busyEvents[1]).assertFalse(); + }); + }); + + describe('ChatComposerCapabilities', () => { + it('keeps route-to-surface composer ownership explicit', 0, () => { + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertTrue(); + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.GeneralChat)).assertTrue(); + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteHome)).assertFalse(); + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteChat)).assertFalse(); + }); + + it('keeps general chat independent from remote-only composer requirements', 0, () => { + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.General); + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertFalse(); + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertFalse(); + }); + + it('keeps remote composer attachments behind the remote connection', 0, () => { + expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.Remote); + expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); + expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertTrue(); + }); + }); + + describe('ConversationViewContract', () => { + it('keeps empty ordinary chat on the unified conversation surface with suggestions', 0, () => { + const emptyItems = ChatTimelineProjector.project([], [], RemoteUiState.emptyActiveTurn(), false); + + expect(ConversationViewContract.surfaceForRoute(false)).assertEqual(ChatSurface.General); + expect(ConversationViewContract.shouldShowSuggestions(true, false, emptyItems)).assertTrue(); + expect(ConversationViewContract.visibleTimelineItems(emptyItems).length).assertEqual(0); + expect(ConversationViewContract.hasRealTimelineItem(emptyItems)).assertFalse(); + }); + + it('removes suggestions on the same page once ordinary chat has real content or is sending', 0, () => { + const emptyItems = ChatTimelineProjector.project([], [], RemoteUiState.emptyActiveTurn(), false); + const messageItems = ChatTimelineProjector.project([ + chatMessage('general-user-1', 'user', 'Write a short intro') + ], [], RemoteUiState.emptyActiveTurn(), false); + + expect(ConversationViewContract.shouldShowSuggestions(true, true, emptyItems)).assertFalse(); + expect(ConversationViewContract.shouldShowSuggestions(true, false, messageItems)).assertFalse(); + expect(ConversationViewContract.visibleTimelineItems(messageItems).length).assertEqual(1); + expect(ConversationViewContract.visibleTimelineItems(messageItems)[0].type).assertEqual('user_message'); + expect(ConversationViewContract.hasRealTimelineItem(messageItems)).assertTrue(); + }); + + it('keeps remote empty conversations blank instead of rendering the old empty card', 0, () => { + const emptyItems = ChatTimelineProjector.project([], [], RemoteUiState.emptyActiveTurn(), false); + + expect(ConversationViewContract.surfaceForRoute(true)).assertEqual(ChatSurface.Remote); + expect(ConversationViewContract.shouldShowSuggestions(false, false, emptyItems)).assertFalse(); + expect(ConversationViewContract.visibleTimelineItems(emptyItems).length).assertEqual(0); + }); + }); + + describe('AppRouteContract', () => { + it('defines stable route ids and session-carrying route params', 0, () => { + const generalParam = AppRouteContract.routeParam('general-session-1'); + const remoteParam = AppRouteContract.routeParam('remote-session-1'); + + expect(`${AppRoute.ChatHome}`).assertEqual('ChatHome'); + expect(`${AppRoute.GeneralChat}`).assertEqual('GeneralChat'); + expect(`${AppRoute.RemoteHome}`).assertEqual('RemoteHome'); + expect(`${AppRoute.RemoteChat}`).assertEqual('RemoteChat'); + expect(AppRouteContract.isSessionRoute(AppRoute.ChatHome)).assertFalse(); + expect(AppRouteContract.isSessionRoute(AppRoute.RemoteHome)).assertFalse(); + expect(AppRouteContract.isSessionRoute(AppRoute.GeneralChat)).assertTrue(); + expect(AppRouteContract.isSessionRoute(AppRoute.RemoteChat)).assertTrue(); + expect(generalParam.sessionId).assertEqual('general-session-1'); + expect(remoteParam.sessionId).assertEqual('remote-session-1'); + }); + + it('derives current route from NavPathStack path names with ChatHome as root', 0, () => { + expect(AppRouteContract.currentRoute([])).assertEqual(AppRoute.ChatHome); + expect(AppRouteContract.currentRoute([AppRoute.RemoteHome])).assertEqual(AppRoute.RemoteHome); + expect(AppRouteContract.currentRoute([ + AppRoute.RemoteHome, + AppRoute.RemoteChat + ])).assertEqual(AppRoute.RemoteChat); + expect(AppRouteContract.currentRoute([ + AppRoute.GeneralChat + ])).assertEqual(AppRoute.GeneralChat); + }); + + it('builds push path specs and suppresses duplicate current routes', 0, () => { + const duplicate = AppRouteContract.pathSpec(AppRoute.RemoteHome, AppRoute.RemoteHome, ''); + const chatHomeDuplicate = AppRouteContract.pathSpec(AppRoute.ChatHome, AppRoute.ChatHome, 'general-session-1'); + const remoteChatSpec = AppRouteContract.pathSpec( + AppRoute.RemoteHome, + AppRoute.RemoteChat, + 'remote-session-1' + ) as AppNavigationPathSpec; + const generalChatSpec = AppRouteContract.pathSpec( + AppRoute.ChatHome, + AppRoute.GeneralChat, + 'general-session-1' + ) as AppNavigationPathSpec; + + expect(duplicate === undefined).assertTrue(); + expect(chatHomeDuplicate === undefined).assertTrue(); + expect(remoteChatSpec.name).assertEqual(AppRoute.RemoteChat); + expect(remoteChatSpec.hasSessionParam()).assertTrue(); + expect(remoteChatSpec.routeParam().sessionId).assertEqual('remote-session-1'); + expect(generalChatSpec.name).assertEqual(AppRoute.GeneralChat); + expect(generalChatSpec.routeParam().sessionId).assertEqual('general-session-1'); + }); + + it('classifies back actions without depending on ArkUI NavPathStack', 0, () => { + expect(AppRouteContract.backAction(AppRoute.ChatHome, true)) + .assertEqual(AppNavigationBackAction.CloseSidebar); + expect(AppRouteContract.backAction(AppRoute.GeneralChat, false)) + .assertEqual(AppNavigationBackAction.CloseActiveChat); + expect(AppRouteContract.backAction(AppRoute.RemoteChat, false)) + .assertEqual(AppNavigationBackAction.CloseActiveChat); + expect(AppRouteContract.backAction(AppRoute.RemoteHome, false)) + .assertEqual(AppNavigationBackAction.PopRemoteHome); + expect(AppRouteContract.backAction(AppRoute.ChatHome, false)) + .assertEqual(AppNavigationBackAction.AllowSystem); + }); + }); + + describe('ChatSessionController', () => { + it('ignores a poll response that completes after the controller stopped', 0, async () => { + const manager = new BlockingPollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + const errors: Object[] = []; + const controller = new ChatSessionController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (error: Object) => { + errors.push(error); + }, + canPoll: (_sessionId: string) => true + }); + + controller.start('session-stale', { + pollVersion: 0, + knownMessageCount: 0, + knownModelCatalogVersion: 0 + }); + await delay(20); + controller.stop(); + manager.resolve(pollResult({ + version: 1, + changed: true, + sessionState: 'idle', + title: 'Stale', + newMessages: [chatMessage('stale-message', 'assistant', 'must not render')], + totalMessageCount: 1 + })); + await delay(20); + + expect(snapshots.length).assertEqual(0); + expect(errors.length).assertEqual(0); + }); + + it('preserves active turn when unchanged poll omits active turn', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + manager.results = [pollResult({ + version: 1, + changed: false, + sessionState: 'running', + title: 'Session', + newMessages: [], + totalMessageCount: 1 + })]; + const controller = new ChatSessionController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + controller.start('session-1', { + pollVersion: 1, + knownMessageCount: 1, + knownModelCatalogVersion: 0 + }, chatMessage('active-turn-1', 'assistant', 'Working', 'active')); + await delay(20); + controller.stop(false); + + expect(snapshots.length).assertEqual(1); + expect(snapshots[0].changed).assertEqual(false); + expect(snapshots[0].activeTurn ? snapshots[0].activeTurn.id : '').assertEqual('active-turn-1'); + }); + + it('keeps completed active turn until final assistant message arrives', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + const controller = new ChatSessionController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + manager.results = [pollResult({ + version: 2, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [], + totalMessageCount: 1, + activeTurn: chatMessage('active-turn-2', 'assistant', 'Final text', 'completed') + })]; + controller.start('session-1', { + pollVersion: 1, + knownMessageCount: 1, + knownModelCatalogVersion: 0 + }, chatMessage('active-turn-2', 'assistant', 'Final text', 'active')); + await delay(20); + + manager.results = [pollResult({ + version: 3, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [], + totalMessageCount: 1 + })]; + await controller.pollNow(); + + manager.results = [pollResult({ + version: 4, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [ + chatMessage('assistant-final-1', 'assistant', 'Final text') + ], + totalMessageCount: 2 + })]; + await controller.pollNow(); + controller.stop(false); + + expect(snapshots.length).assertEqual(3); + expect(snapshots[0].activeTurn ? snapshots[0].activeTurn.status : '').assertEqual('completed'); + expect(snapshots[1].activeTurn ? snapshots[1].activeTurn.id : '').assertEqual('active-turn-2'); + expect(snapshots[2].activeTurn ? snapshots[2].activeTurn.id : '').assertEqual(''); + }); + + it('resyncs when a completed assistant message is updated during the settle window', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + const controller = new ChatSessionController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + manager.results = [pollResult({ + version: 2, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [ + chatMessage('turn-1-assistant', 'assistant', '') + ], + totalMessageCount: 2 + })]; + controller.start('session-1', { + pollVersion: 1, + knownMessageCount: 1, + knownModelCatalogVersion: 0 + }, chatMessage('active-turn-1', 'assistant', '', 'active')); + await delay(20); + + manager.results = [pollResult({ + version: 3, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [], + totalMessageCount: 2 + })]; + await controller.pollNow(); + controller.stop(false); + + expect(snapshots.length).assertEqual(2); + expect(snapshots[0].shouldSyncAfterTurnEnded).assertEqual(true); + expect(snapshots[1].newMessages.length).assertEqual(0); + expect(snapshots[1].shouldSyncAfterTurnEnded).assertEqual(true); + }); + }); +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets new file mode 100644 index 0000000000..409a5bdb3d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -0,0 +1,1079 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; +import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; +import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; +import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; +import { ConversationEvent } from '../main/ets/services/ConversationEvent'; +import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; +import { Encoding } from '../main/ets/services/Encoding'; +import { + GeneralChatDraftStore, + GeneralChatLocalStore, + GeneralChatSendResult, + GeneralChatStreamCallbacks, + GeneralChatStreamObserver +} from '../main/ets/services/general-chat/GeneralChatPort'; +import { GeneralChatApiClient } from '../main/ets/services/general-chat/GeneralChatApiClient'; +import { GeneralChatBootstrapController } from '../main/ets/services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ets/services/general-chat/GeneralChatCommandController'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigValidator +} from '../main/ets/services/general-chat/GeneralChatConfigStore'; +import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; +import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; +import { + GeneralChatHttpHeaders, + GeneralChatHttpMethod, + GeneralChatHttpResponse, + GeneralChatHttpTransport, + GeneralChatTokenProvider +} from '../main/ets/services/general-chat/GeneralChatHttpTransport'; +import { GeneralChatRepository } from '../main/ets/services/general-chat/GeneralChatRepository'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../main/ets/services/general-chat/GeneralChatServiceState'; +import { GeneralChatStreamCoordinator } from '../main/ets/services/general-chat/GeneralChatStreamCoordinator'; +import { GeneralChatStreamLifecycleController } from '../main/ets/services/general-chat/GeneralChatStreamLifecycleController'; +import { + ModelProviderGeneralChatAdapter, + ModelProviderMessage, + ModelProviderProtocol +} from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; +import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; +import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; +import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; +import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; +import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; +import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; +import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler +} from '../main/ets/services/RemoteFileDownloadController'; +import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; +import { + RemoteModelClient, + RemoteModelController, + RemoteModelPreferenceStore +} from '../main/ets/services/RemoteModelController'; +import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; +import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; +import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; +import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; +import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; +import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; +import { RemoteUiState } from '../main/ets/services/RemoteUiState'; +import { + VoiceInputLifecycleCallbacks, + VoiceInputLifecycleController, + VoiceInputRouteSnapshot +} from '../main/ets/services/VoiceInputLifecycleController'; +import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; +import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES +} from '../main/ets/pages/components/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + AppNavigationBackAction, + AppNavigationPathSpec, + AppRoute, + AppRouteContract +} from '../main/ets/pages/navigation/AppRouteContract'; +import { TimeFormat } from '../main/ets/services/TimeFormat'; +import { X25519 } from '../main/ets/services/X25519'; +import { + ChatMessage, + FileInfo, + ImageAttachment, + PollSessionResult, + RecentWorkspaceEntry, + AssistantEntry, + CreateSessionOptions, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteModelCatalog, + ReadFileResult, + RemoteSession, + SelectedImageAttachment, + SessionListResult, + SessionMessagesResult, + SessionSummary, + WorkspaceInfo +} from '../main/ets/model/RemoteModels'; + +import { + ToolInputFixture, + CryptoImageFixture, + CryptoCommandFixture, + CryptoSimpleCommandFixture, + GeneralChatRecordedRequest, + FakeRemoteWorkspaceDataSource, + FakeRemoteCryptoCipher, + hexToBytes, + bytesToHex, + fixedRandomBytes, + expectCommandJson, + connectedPeers, + chatMessage, + remoteSession, + selectedImage, + remoteModelCatalog, + activeChatMessage, + chatMessageWithImage, + imageAttachment, + pollResult, + delay, + FakePollSessionManager, + BlockingPollSessionManager, + FakeGeneralChatLocalStore, + FailingGeneralChatAdapter, + PartialFailingGeneralChatAdapter, + FakeGeneralChatTokenProvider, + FakeGeneralChatHttpTransport, + FakeRemoteHeartbeatScheduler, + FakeGeneralChatDraftScheduler, + FakeVoiceInputLifecycleService, + FakeRemoteFileDownloadScheduler, + FakeRemoteFileDownloadClient, + RemoteDownloadStatusRecord, + FakeRemoteToolActionClient, + FakeRemoteModelClient, + FakeRemoteModelPreferenceStore, + RemoteModelProjectionRecord, + FakeRemoteSessionClient, + RemoteSessionProjectionRecord, + FakeRemoteChatCommandClient, + RemoteChatMessagesProjectionRecord, + FakeGeneralChatCommandClient, + FakeGeneralChatConfigStore +} from './LocalTestFixtures'; + +export default function transportAndGeneralChatUnitTest() { + describe('RemoteDescriptorParser', () => { + it('parses hash route URLs', 0, () => { + const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/r/mobile#/pair?room=room-a&pk=public-key'); + expect(descriptor.roomId).assertEqual('room-a'); + expect(descriptor.publicKey).assertEqual('public-key'); + expect(descriptor.relayUrl).assertEqual('https://relay.example.com'); + }); + + it('normalizes relay websocket URLs', 0, () => { + const descriptor = RemoteDescriptorParser.parse('https://app.example.com/#/pair?room=room-b&pk=key-b&relay=wss%3A%2F%2Frelay.example.com%2Fws'); + expect(descriptor.relayUrl).assertEqual('https://relay.example.com'); + }); + + it('parses account pairing metadata', 0, () => { + const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-account&pk=key-account&auth=account&user=alice'); + expect(descriptor.accountAuth).assertTrue(); + expect(descriptor.accountUsername).assertEqual('alice'); + }); + + it('parses raw query strings', 0, () => { + const descriptor = RemoteDescriptorParser.parse('room=room-c&pk=key%2Bc%3D&relay=http%3A%2F%2F127.0.0.1%3A30333'); + expect(descriptor.roomId).assertEqual('room-c'); + expect(descriptor.publicKey).assertEqual('key+c='); + expect(descriptor.relayUrl).assertEqual('http://127.0.0.1:30333'); + }); + + it('rejects URLs missing room', 0, () => { + let didThrow = false; + try { + RemoteDescriptorParser.parse('https://relay.example.com/#/pair?pk=key-only'); + } catch (_err) { + didThrow = true; + } + expect(didThrow).assertEqual(true); + }); + + it('rejects URLs missing pk', 0, () => { + let didThrow = false; + try { + RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-only'); + } catch (_err) { + didThrow = true; + } + expect(didThrow).assertEqual(true); + }); + }); + + describe('RemotePairingPolicy', () => { + it('prefills account username from QR only over the default install id', 0, () => { + const policy = new RemotePairingPolicy(); + const projection = policy.projection('https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice'); + + expect(projection.requiresAccountAuth).assertTrue(); + expect(projection.accountUsername).assertEqual('alice'); + expect(policy.userIdAfterProjection('harmony-device', 'harmony-device', projection)).assertEqual('alice'); + expect(policy.userIdAfterProjection('manual-user', 'harmony-device', projection)).assertEqual('manual-user'); + }); + + it('blocks passwordless account reconnects and builds encrypted pairing identity', 0, () => { + const policy = new RemotePairingPolicy(); + const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice'); + + expect(policy.shouldAutoReconnect({ + autoReconnectAttempted: false, + remoteUrl: 'https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice', + userId: 'alice', + requiresAccountAuth: true + })).assertFalse(); + + const identity = policy.identityForConnect(descriptor, '', 'harmony-device', 'secret-password', false); + expect(identity.userId).assertEqual('alice'); + expect(identity.password).assertEqual('secret-password'); + }); + + it('keeps ordinary pairing passwordless and auto reconnectable', 0, () => { + const policy = new RemotePairingPolicy(); + const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-a&pk=key-a'); + const identity = policy.identityForConnect(descriptor, '', 'harmony-device', '', true); + + expect(identity.userId).assertEqual('harmony-device'); + expect(identity.password || '').assertEqual(''); + expect(policy.shouldAutoReconnect({ + autoReconnectAttempted: false, + remoteUrl: 'https://relay.example.com/#/pair?room=room-a&pk=key-a', + userId: 'harmony-device', + requiresAccountAuth: false + })).assertTrue(); + }); + }); + + describe('X25519', () => { + const alicePrivate = '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'; + const alicePublic = '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a'; + const bobPrivate = '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb'; + const bobPublic = 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f'; + const sharedSecret = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742'; + const mobileWebPrivate = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const mobileWebPublic = '8f40c5adb68f25624ae5b214ea767a6ec94d829d3d7b5e1ad1ba6f3e2138285f'; + const desktopPrivate = 'f0e1d2c3b4a5968778695a4b3c2d1e0f00112233445566778899aabbccddeeff'; + const desktopPublic = 'ed99ba8df1a6a8c6396beafc3a42b8212fde389a7663217e170e82b68377e858'; + const mobileWebSharedSecret = '251b4e394babbef8dc30269e5a065946c08d89d6df010376549baa469c552f4b'; + + it('matches RFC 7748 public key vectors', 0, () => { + expect(bytesToHex(X25519.scalarMultBase(hexToBytes(alicePrivate)))).assertEqual(alicePublic); + expect(bytesToHex(X25519.scalarMultBase(hexToBytes(bobPrivate)))).assertEqual(bobPublic); + }); + + it('matches RFC 7748 shared secret vector from both peers', 0, () => { + expect(bytesToHex(X25519.scalarMult(hexToBytes(alicePrivate), hexToBytes(bobPublic)))).assertEqual(sharedSecret); + expect(bytesToHex(X25519.scalarMult(hexToBytes(bobPrivate), hexToBytes(alicePublic)))).assertEqual(sharedSecret); + }); + + it('matches the mobile-web noble-curves shared secret shape', 0, () => { + expect(bytesToHex(X25519.scalarMultBase(hexToBytes(mobileWebPrivate)))).assertEqual(mobileWebPublic); + expect(bytesToHex(X25519.scalarMultBase(hexToBytes(desktopPrivate)))).assertEqual(desktopPublic); + expect(bytesToHex(X25519.scalarMult(hexToBytes(mobileWebPrivate), hexToBytes(desktopPublic)))) + .assertEqual(mobileWebSharedSecret); + expect(bytesToHex(X25519.scalarMult(hexToBytes(desktopPrivate), hexToBytes(mobileWebPublic)))) + .assertEqual(mobileWebSharedSecret); + }); + }); + + describe('RemoteCrypto', () => { + it('encrypts and decrypts JSON between derived peers', 0, async () => { + const peers = connectedPeers(); + const alice = peers[0]; + const bob = peers[1]; + const command: CryptoCommandFixture = { + cmd: 'send_message', + session_id: 'session-1', + content: 'hello from mobile', + agent_type: 'code', + image_contexts: [{ + id: 'image-1', + data_url: 'data:image/png;base64,aGVsbG8=', + mime_type: 'image/png' + }] + }; + + const encrypted = await alice.encryptJson(command); + const decrypted = await bob.decryptJson(encrypted); + + expect(decrypted.cmd).assertEqual(command.cmd); + expect(decrypted.session_id).assertEqual(command.session_id); + expect(decrypted.content).assertEqual(command.content); + expect(decrypted.agent_type).assertEqual(command.agent_type); + expect(decrypted.image_contexts.length).assertEqual(1); + expect(decrypted.image_contexts[0].id).assertEqual('image-1'); + expect(decrypted.image_contexts[0].mime_type).assertEqual('image/png'); + }); + + it('produces relay-compatible encrypted payload shape', 0, async () => { + const peers = connectedPeers(); + const command: CryptoSimpleCommandFixture = { + cmd: 'get_workspace_info' + }; + const encrypted = await peers[0].encryptJson(command); + const keys = Object.keys(encrypted).sort().join(','); + const nonce = Encoding.base64ToBytes(encrypted.nonce); + const encryptedData = Encoding.base64ToBytes(encrypted.encrypted_data); + + expect(keys).assertEqual('encrypted_data,nonce'); + expect(nonce.length).assertEqual(12); + expect(encryptedData.length).assertLarger(16); + expect(encrypted.encrypted_data.indexOf('get_workspace_info')).assertEqual(-1); + const decrypted = await peers[1].decryptJson(encrypted); + expect(decrypted.cmd).assertEqual('get_workspace_info'); + }); + + it('rejects use before shared-key derivation', 0, async () => { + let didThrow = false; + try { + const command: CryptoSimpleCommandFixture = { + cmd: 'ping' + }; + const privateKey = hexToBytes('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'); + await new RemoteCrypto({ + cipher: new FakeRemoteCryptoCipher(), + keyPair: { + privateKey, + publicKey: X25519.scalarMultBase(privateKey) + }, + randomBytes: fixedRandomBytes + }).encryptJson(command); + } catch (_err) { + didThrow = true; + } + expect(didThrow).assertEqual(true); + }); + }); + + describe('TimeFormat', () => { + const now = Date.parse('2026-06-10T12:00:00.000Z'); + + it('formats recent timestamps as just now', 0, () => { + expect(TimeFormat.relative('2026-06-10T11:59:45.000Z', now)).assertEqual('刚刚'); + }); + + it('formats minute and hour relative timestamps', 0, () => { + expect(TimeFormat.relative('2026-06-10T11:42:00.000Z', now)).assertEqual('18 分钟前'); + expect(TimeFormat.relative('2026-06-10T09:00:00.000Z', now)).assertEqual('3 小时前'); + }); + + it('formats older timestamps as days or dates', 0, () => { + expect(TimeFormat.relative('2026-06-08T12:00:00.000Z', now)).assertEqual('2 天前'); + expect(TimeFormat.relative('2026-05-30T12:00:00.000Z', now)).assertEqual('2026-05-30'); + }); + + it('does not show invalid timestamps as just now', 0, () => { + expect(TimeFormat.relative('', now)).assertEqual('时间未知'); + expect(TimeFormat.relative('not-a-date', now)).assertEqual('时间未知'); + }); + + it('formats numeric timestamps and future timestamps safely', 0, () => { + expect(TimeFormat.relative('1781091720', now)).assertEqual('18 分钟前'); + expect(TimeFormat.timestampMs('1781091720000')).assertEqual(now - 18 * 60 * 1000); + expect(TimeFormat.timestampMs('1781091720000000')).assertEqual(now - 18 * 60 * 1000); + expect(TimeFormat.timestampMs('1781091720000000000')).assertEqual(now - 18 * 60 * 1000); + expect(TimeFormat.relative('2026-06-12T12:00:00.000Z', now)).assertEqual('2026-06-12'); + }); + }); + + describe('GeneralChatRepository', () => { + it('persists a complete ordinary chat and restores it without Remote state', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + const session = await repository.createSession('帮我整理一份发布计划'); + const result = await repository.sendMessage(session.id, '先给我三个步骤'); + await repository.recordAssistantMessage(session.id, result.assistantMessage); + + expect(repository.listSessions().length).assertEqual(1); + expect(repository.listSessions()[0].agentType).assertEqual('chat'); + expect(repository.listSessions()[0].messageCount).assertEqual(2); + + const restored = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + await restored.restoreFromLocal(); + const restoredMessages = await restored.messages(session.id); + expect(restored.listSessions().length).assertEqual(1); + expect(restoredMessages.length).assertEqual(2); + expect(restoredMessages[0].role).assertEqual('user'); + expect(restoredMessages[1].role).assertEqual('assistant'); + }); + + it('renames ordinary chat sessions through the local repository', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + const session = await repository.createSession('原始标题'); + await repository.renameSession(session.id, '新的标题'); + + expect(repository.listSessions()[0].title).assertEqual('新的标题'); + expect(localStore.sessions[0].title).assertEqual('新的标题'); + }); + + it('archives ordinary chat sessions and restores the archived state', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + const session = await repository.createSession('待归档会话'); + await repository.archiveSession(session.id, true); + + const restored = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + await restored.restoreFromLocal(); + expect(restored.listSessions()[0].status).assertEqual('archived'); + + await restored.archiveSession(session.id, false); + expect(restored.listSessions()[0].status).assertEqual('ready'); + expect(localStore.sessions[0].status).assertEqual('ready'); + }); + + it('persists drafts independently and removes session-owned data on delete', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + const session = await repository.createSession('待删除会话'); + await repository.saveDraft('new-chat', '首页草稿'); + await repository.saveDraft(session.id, '会话草稿'); + await repository.sendMessage(session.id, '一条消息'); + + expect(await repository.draft('new-chat')).assertEqual('首页草稿'); + expect(await repository.draft(session.id)).assertEqual('会话草稿'); + await repository.deleteSession(session.id); + + expect(repository.listSessions().length).assertEqual(0); + expect((await repository.messages(session.id)).length).assertEqual(0); + expect(await repository.draft(session.id)).assertEqual(''); + expect(await repository.draft('new-chat')).assertEqual('首页草稿'); + }); + + it('persists a failed user message when the provider rejects before streaming', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new FailingGeneralChatAdapter(), localStore); + const session = await repository.createSession('失败状态'); + let failed = false; + try { + await repository.sendMessage(session.id, '请重试我'); + } catch (_err) { + failed = true; + } + + const messages = await repository.messages(session.id); + expect(failed).assertEqual(true); + expect(messages.length).assertEqual(1); + expect(messages[0].status).assertEqual('failed'); + }); + + it('keeps the user message sent when a stream fails after receiving content', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new PartialFailingGeneralChatAdapter(), localStore); + const session = await repository.createSession('流中断状态'); + try { + await repository.sendMessage(session.id, '保留这条消息'); + } catch (_err) { + } + + const messages = await repository.messages(session.id); + expect(messages.length).assertEqual(1); + expect(messages[0].status).assertEqual('sent'); + }); + }); + + describe('GeneralChatExportFormatter', () => { + it('exports visible conversation content as Markdown without hidden error detail', 0, () => { + const session: RemoteSession = { + id: 'chat-export', + title: '发布计划', + agentType: 'chat', + status: 'ready', + updatedAt: '2026-07-20T10:00:00.000Z', + createdAt: '2026-07-20T09:00:00.000Z', + messageCount: 2 + }; + const failedAssistant = chatMessage('assistant-1', 'assistant', '先检查发布清单。', 'failed'); + failedAssistant.detail = 'secret retry context'; + const markdown = GeneralChatExportFormatter.markdown(session, [ + chatMessage('user-1', 'user', '帮我整理发布计划'), + failedAssistant + ]); + + expect(markdown).assertEqual('# 发布计划\n\n## 用户\n\n帮我整理发布计划\n\n## BitFun\n\n先检查发布清单。\n'); + expect(markdown.indexOf('secret retry context')).assertEqual(-1); + }); + }); + + describe('GeneralChatStreamCoordinator', () => { + it('batches deltas into one stable active message and flushes before completion', 0, () => { + const coordinator = new GeneralChatStreamCoordinator(); + const first = coordinator.begin('chat-1', 'turn-1'); + const flushed: ChatMessage[] = []; + coordinator.append('chat-1', '第一段\n\n\n', () => true, (message: ChatMessage) => { + flushed.push(message); + }); + coordinator.append('chat-1', '第二段', () => true, (message: ChatMessage) => { + flushed.push(message); + }); + const terminal = coordinator.flush('chat-1', true); + + expect(first.id).assertEqual('active-turn-1'); + expect(terminal?.id || '').assertEqual(first.id); + expect(terminal?.text || '').assertEqual('第一段\n\n第二段'); + expect(coordinator.text()).assertEqual('第一段\n\n第二段'); + coordinator.reset(); + }); + + it('rejects late deltas after reset or from another session', 0, () => { + const coordinator = new GeneralChatStreamCoordinator(); + coordinator.begin('chat-1', 'turn-1'); + coordinator.reset(); + + expect(coordinator.append('chat-1', 'late', () => true, (_message: ChatMessage) => {})) + .assertEqual(false); + expect(coordinator.append('chat-2', 'wrong', () => true, (_message: ChatMessage) => {})) + .assertEqual(false); + expect(coordinator.hasActiveStream()).assertEqual(false); + }); + }); + + describe('GeneralChatStreamLifecycleController', () => { + it('owns stream request lifecycle state around coordinator updates', 0, () => { + const controller = new GeneralChatStreamLifecycleController(); + const active = controller.begin('chat-1'); + const flushed: ChatMessage[] = []; + + controller.setRequestInFlight(true); + expect(controller.isRequestInFlight()).assertEqual(true); + expect(active.id.indexOf('active-')).assertEqual(0); + expect(controller.append('chat-1', 'hello', () => true, (message: ChatMessage) => { + flushed.push(message); + })).assertEqual(true); + const terminal = controller.flush('chat-1', true); + expect(terminal?.text || '').assertEqual('hello'); + expect(controller.text()).assertEqual('hello'); + + controller.markRequestCancelled(); + expect(controller.takeRequestCancelled()).assertEqual(true); + expect(controller.takeRequestCancelled()).assertEqual(false); + controller.setPendingFinalMessage(chatMessage('assistant-final', 'assistant', 'stopped', 'cancelled')); + expect(controller.takePendingFinalMessage()?.id || '').assertEqual('assistant-final'); + expect(controller.takePendingFinalMessage() === undefined).assertTrue(); + + controller.setRequestInFlight(false); + controller.reset(); + expect(controller.isRequestInFlight()).assertEqual(false); + expect(controller.currentSessionId()).assertEqual(''); + expect(controller.hasActiveStream()).assertEqual(false); + }); + }); + + describe('GeneralChatServiceStatus', () => { + it('derives readiness only from complete model configuration', 0, () => { + expect(GeneralChatServiceStatus.fromConfiguration('https://api.example.com', 'model', true)) + .assertEqual(GeneralChatServiceState.Ready); + expect(GeneralChatServiceStatus.fromConfiguration('', 'model', true)) + .assertEqual(GeneralChatServiceState.Unconfigured); + }); + + it('classifies authentication, network and provider failures independently from Remote state', 0, () => { + expect(GeneralChatServiceStatus.fromError(RemoteI18n.t('generalChat.authenticationFailed'))) + .assertEqual(GeneralChatServiceState.AuthExpired); + expect(GeneralChatServiceStatus.fromError('Network connection timeout.')) + .assertEqual(GeneralChatServiceState.Offline); + expect(GeneralChatServiceStatus.fromError(RemoteI18n.t('generalChat.serviceUnavailable'))) + .assertEqual(GeneralChatServiceState.Failed); + }); + }); + + describe('ChatComposerPolicy', () => { + it('keeps ordinary chat send enabled across every Remote connection state', 0, () => { + const states = ['idle', 'pairing', 'connected', 'reconnecting', 'failed', 'disconnected']; + states.forEach((state: string) => { + expect(ChatComposerPolicy.canSend('普通问题', 0, false, false, state)).assertEqual(true); + }); + }); + + it('still gates Remote chat when the desktop connection is unavailable', 0, () => { + expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'failed')).assertEqual(false); + expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'connected')).assertEqual(true); + expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'reconnecting')).assertEqual(true); + }); + }); + + describe('GeneralChatApiClient', () => { + it('builds authenticated session and turn requests', 0, async () => { + const transport = new FakeGeneralChatHttpTransport(); + transport.responses = [{ + statusCode: 200, + body: '{"session":{"id":"chat-1","title":"计划","status":"ready","created_at":"now","updated_at":"now","message_count":0}}' + }, { + statusCode: 202, + body: '{"turn_id":"turn-1","accepted_at":"now","version":1}' + }]; + const client = new GeneralChatApiClient( + 'https://chat.example.com/', + new FakeGeneralChatTokenProvider(), + transport + ); + + const session = await client.createSession(' 计划 '); + const receipt = await client.sendTurn(session.session.id, ' 生成步骤 ', ' chat-model '); + + expect(session.session.id).assertEqual('chat-1'); + expect(receipt.turn_id).assertEqual('turn-1'); + expect(transport.requests[0].url).assertEqual('https://chat.example.com/api/mobile/v1/chat/sessions'); + expect(transport.requests[0].authorization).assertEqual('Bearer test-token'); + expect(transport.requests[0].body).assertEqual('{"title":"计划"}'); + expect(transport.requests[1].method).assertEqual(GeneralChatHttpMethod.Post); + expect(transport.requests[1].body).assertEqual('{"content":"生成步骤","model":"chat-model"}'); + }); + + it('maps authentication failures without exposing response bodies', 0, async () => { + const transport = new FakeGeneralChatHttpTransport(); + transport.responses = [{ statusCode: 401, body: '{"token":"must-not-leak"}' }]; + const client = new GeneralChatApiClient( + 'https://chat.example.com', + new FakeGeneralChatTokenProvider(), + transport + ); + let message = ''; + try { + await client.capabilities(); + } catch (err) { + message = err instanceof Error ? err.message : `${err}`; + } + + expect(message).assertEqual('General chat authentication expired.'); + expect(message.indexOf('must-not-leak')).assertEqual(-1); + }); + }); + + describe('GeneralChatConfigValidator', () => { + it('accepts an existing key when URL and model are valid', 0, () => { + const error = GeneralChatConfigValidator.validate({ + apiUrl: 'https://chat.example.com/', + apiKey: '', + modelName: 'chat-model', + clearApiKey: false + }, true); + expect(error).assertEqual(''); + }); + + it('rejects unsafe URLs and incomplete new configurations', 0, () => { + const urlError = GeneralChatConfigValidator.validate({ + apiUrl: 'file:///tmp/chat', + apiKey: 'secret', + modelName: 'chat-model', + clearApiKey: false + }, false); + const keyError = GeneralChatConfigValidator.validate({ + apiUrl: 'https://chat.example.com', + apiKey: '', + modelName: 'chat-model', + clearApiKey: false + }, false); + expect(urlError).assertEqual('API URL 必须以 http:// 或 https:// 开头。'); + expect(keyError).assertEqual('请输入 API Key。'); + }); + }); + + describe('ModelProviderGeneralChatAdapter', () => { + it('infers provider protocol and request URL without a protocol setting', 0, () => { + const openBitFunProtocol = ModelProviderGeneralChatAdapter.resolveProtocol('https://api.openbitfun.com'); + const customProtocol = ModelProviderGeneralChatAdapter.resolveProtocol('https://llm.example.com/v1'); + + expect(openBitFunProtocol).assertEqual(ModelProviderProtocol.Anthropic); + expect(customProtocol).assertEqual(ModelProviderProtocol.OpenAi); + expect(ModelProviderGeneralChatAdapter.resolveRequestUrl( + 'https://api.openbitfun.com/', + openBitFunProtocol + )).assertEqual('https://api.openbitfun.com/v1/messages'); + expect(ModelProviderGeneralChatAdapter.resolveRequestUrl( + 'https://llm.example.com/v1', + customProtocol + )).assertEqual('https://llm.example.com/v1/chat/completions'); + }); + + it('extracts text from Anthropic and OpenAI compatible responses', 0, () => { + const anthropicText = ModelProviderGeneralChatAdapter.responseText( + '{"content":[{"type":"text","text":"你好"}]}', + ModelProviderProtocol.Anthropic + ); + const openAiText = ModelProviderGeneralChatAdapter.responseText( + '{"choices":[{"message":{"content":"Hello"}}]}', + ModelProviderProtocol.OpenAi + ); + + expect(anthropicText).assertEqual('你好'); + expect(openAiText).assertEqual('Hello'); + }); + + it('places the BitFun system prompt in both request formats', 0, () => { + const messages: ModelProviderMessage[] = [{ role: 'user', content: 'BitFun 是什么?' }]; + const anthropicBody = ModelProviderGeneralChatAdapter.buildRequestBody( + 'model-a', + messages, + ModelProviderProtocol.Anthropic + ); + const openAiBody = ModelProviderGeneralChatAdapter.buildRequestBody( + 'model-b', + messages, + ModelProviderProtocol.OpenAi + ); + + expect(anthropicBody.indexOf('local AI workbench')).assertLarger(-1); + expect(anthropicBody.indexOf('"system"')).assertLarger(-1); + expect(openAiBody.indexOf('"role":"system"')).assertLarger(-1); + expect(openAiBody.indexOf('blockchain')).assertLarger(-1); + }); + }); + + describe('ModelProviderSseParser', () => { + it('parses Anthropic deltas split across network chunks', 0, () => { + const parser = new ModelProviderSseParser(ModelProviderProtocol.Anthropic); + const first = parser.feed( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg-1"}}\n\n' + + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_' + ); + const second = parser.feed('delta","text":"你好"}}\n\n'); + const third = parser.feed( + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"!"}}\n\n' + ); + + expect(first.length).assertEqual(0); + expect(second.join('')).assertEqual('你好'); + expect(third.join('')).assertEqual('!'); + expect(parser.responseId()).assertEqual('msg-1'); + }); + + it('parses OpenAI CRLF events and ignores the done marker', 0, () => { + const parser = new ModelProviderSseParser(ModelProviderProtocol.OpenAi); + const deltas = parser.feed( + 'data: {"id":"chat-1","choices":[{"delta":{"content":"Hello"}}]}\r\n\r\n' + + 'data: {"id":"chat-1","choices":[{"delta":{"content":" world"}}]}\r\n\r\n' + + 'data: [DONE]\r\n\r\n' + ); + + expect(deltas.join('')).assertEqual('Hello world'); + expect(parser.responseId()).assertEqual('chat-1'); + expect(parser.finish().length).assertEqual(0); + }); + }); + + describe('GeneralChatEventMapper', () => { + it('projects ordered stream deltas and ignores duplicate versions', 0, () => { + let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'event-1', + version: 1, + session_id: 'chat-1', + turn_id: 'turn-1', + type: 'message.delta', + delta: '你好' + }); + const duplicate = GeneralChatEventMapper.apply(projection, { + event_id: 'event-1', + version: 1, + session_id: 'chat-1', + turn_id: 'turn-1', + type: 'message.delta', + delta: '不应重复' + }); + projection = GeneralChatEventMapper.apply(duplicate, { + event_id: 'event-2', + version: 2, + session_id: 'chat-1', + turn_id: 'turn-1', + type: 'turn.completed' + }); + + expect(projection.text).assertEqual('你好'); + expect(projection.status).assertEqual('completed'); + expect(projection.version).assertEqual(2); + }); + + it('rejects stream version gaps so callers can reload a snapshot', 0, () => { + const projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1', 3); + let didThrow = false; + try { + GeneralChatEventMapper.apply(projection, { + event_id: 'event-5', + version: 5, + session_id: 'chat-1', + turn_id: 'turn-1', + type: 'message.delta', + delta: '缺少版本 4' + }); + } catch (_err) { + didThrow = true; + } + expect(didThrow).assertEqual(true); + }); + + it('preserves text and tool event order while updating a stable tool item', 0, () => { + let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'text-1', version: 1, session_id: 'chat-1', turn_id: 'turn-1', + type: 'message.delta', delta: 'before' + }); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'tool-event-1', version: 2, session_id: 'chat-1', turn_id: 'turn-1', + type: 'tool.started', tool: { id: 'tool-1', name: 'WebSearch', status: 'running' } + }); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'tool-event-2', version: 3, session_id: 'chat-1', turn_id: 'turn-1', + type: 'tool.completed', tool: { id: 'tool-1', name: 'WebSearch', status: 'completed' } + }); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'text-2', version: 4, session_id: 'chat-1', turn_id: 'turn-1', + type: 'message.delta', delta: 'after' + }); + + expect(projection.items.length).assertEqual(3); + expect(projection.items[0].content).assertEqual('before'); + expect(projection.items[1].tool?.id || '').assertEqual('tool-1'); + expect(projection.items[1].tool?.status || '').assertEqual('completed'); + expect(projection.items[2].content).assertEqual('after'); + expect(projection.tools.length).assertEqual(1); + }); + + it('projects permission and capability states and ignores late terminal events', 0, () => { + let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'permission-1', version: 1, session_id: 'chat-1', turn_id: 'turn-1', + type: 'permission.required', + tool: { id: 'tool-1', name: 'Write', status: 'waiting' } + }); + expect(projection.status).assertEqual('pending_confirmation'); + expect(projection.tools[0].status || '').assertEqual('pending_confirmation'); + + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'capability-1', version: 2, session_id: 'chat-1', turn_id: 'turn-1', + type: 'capability.required', capability: 'remote_workspace' + }); + expect(projection.status).assertEqual('capability_required'); + expect(projection.capabilityRequired).assertEqual('remote_workspace'); + + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'cancel-1', version: 3, session_id: 'chat-1', turn_id: 'turn-1', + type: 'turn.cancelled' + }); + const terminal = GeneralChatEventMapper.apply(projection, { + event_id: 'late-1', version: 4, session_id: 'chat-1', turn_id: 'turn-1', + type: 'message.delta', delta: 'late' + }); + expect(terminal.status).assertEqual('cancelled'); + expect(terminal.version).assertEqual(3); + expect(terminal.text).assertEqual(''); + }); + }); + + describe('RemoteCommandFactory', () => { + it('builds workspace and assistant commands', 0, () => { + expectCommandJson(RemoteCommandFactory.getWorkspaceInfo(), '{"cmd":"get_workspace_info"}'); + expectCommandJson(RemoteCommandFactory.listRecentWorkspaces(), '{"cmd":"list_recent_workspaces"}'); + expectCommandJson(RemoteCommandFactory.setWorkspace('/repo'), '{"cmd":"set_workspace","path":"/repo"}'); + expectCommandJson(RemoteCommandFactory.listAssistants(), '{"cmd":"list_assistants"}'); + expectCommandJson(RemoteCommandFactory.setAssistant('/assistant'), '{"cmd":"set_assistant","path":"/assistant"}'); + }); + + it('builds session list and creation commands without unsupported fields', 0, () => { + const listCommand = RemoteCommandFactory.listSessions('/repo', 8, 16, ' auth '); + expectCommandJson(listCommand, '{"cmd":"list_sessions","workspace_path":"/repo","limit":8,"offset":16,"query":"auth"}'); + expect(JSON.stringify(listCommand).indexOf('agent_type')).assertEqual(-1); + + const createCommand = RemoteCommandFactory.createSession({ + agentType: 'cowork', + title: '', + instruction: 'hello' + }, '/repo'); + expectCommandJson(createCommand, '{"cmd":"create_session","agent_type":"cowork","session_name":"Remote Cowork Session","workspace_path":"/repo"}'); + expect(JSON.stringify(createCommand).indexOf('run_mode')).assertEqual(-1); + }); + + it('builds message and polling commands', 0, () => { + expectCommandJson(RemoteCommandFactory.sendMessage('s1', 'hello', 'code'), '{"cmd":"send_message","session_id":"s1","content":"hello","agent_type":"code"}'); + expectCommandJson(RemoteCommandFactory.sendMessage('s1', 'look', 'code', [{ + id: 'img1', + data_url: 'data:image/png;base64,abc', + mime_type: 'image/png' + }]), '{"cmd":"send_message","session_id":"s1","content":"look","agent_type":"code","image_contexts":[{"id":"img1","data_url":"data:image/png;base64,abc","mime_type":"image/png"}]}'); + expectCommandJson(RemoteCommandFactory.getSessionMessages('s1'), '{"cmd":"get_session_messages","session_id":"s1"}'); + expectCommandJson(RemoteCommandFactory.pollSession('s1', 7, 3, 2), '{"cmd":"poll_session","session_id":"s1","since_version":7,"known_msg_count":3,"known_model_catalog_version":2}'); + }); + + it('builds model and session lifecycle commands', 0, () => { + expectCommandJson(RemoteCommandFactory.getModelCatalog('s1'), '{"cmd":"get_model_catalog","session_id":"s1"}'); + expectCommandJson(RemoteCommandFactory.setSessionModel('s1', 'model-a'), '{"cmd":"set_session_model","session_id":"s1","model_id":"model-a"}'); + expectCommandJson(RemoteCommandFactory.cancelTask('s1'), '{"cmd":"cancel_task","session_id":"s1"}'); + expectCommandJson(RemoteCommandFactory.cancelTask('s1', 'turn-9'), '{"cmd":"cancel_task","session_id":"s1","turn_id":"turn-9"}'); + expectCommandJson(RemoteCommandFactory.renameSession('s1', 'Next'), '{"cmd":"update_session_title","session_id":"s1","title":"Next"}'); + expectCommandJson(RemoteCommandFactory.deleteSession('s1'), '{"cmd":"delete_session","session_id":"s1"}'); + expectCommandJson(RemoteCommandFactory.ping(), '{"cmd":"ping"}'); + }); + + it('builds tool and file commands', 0, () => { + const answers: RemoteQuestionAnswerPayload = { answer: 'yes', '0': 'yes' }; + + expectCommandJson(RemoteCommandFactory.confirmTool('tool-1'), '{"cmd":"confirm_tool","tool_id":"tool-1"}'); + expectCommandJson(RemoteCommandFactory.rejectTool('tool-1', 'no'), '{"cmd":"reject_tool","tool_id":"tool-1","reason":"no"}'); + expectCommandJson(RemoteCommandFactory.cancelTool('tool-1', 'stop'), '{"cmd":"cancel_tool","tool_id":"tool-1","reason":"stop"}'); + expectCommandJson(RemoteCommandFactory.answerQuestion('tool-2', answers), '{"cmd":"answer_question","tool_id":"tool-2","answers":{"0":"yes","answer":"yes"}}'); + expectCommandJson(RemoteCommandFactory.getFileInfo('/tmp/a.txt', 's1'), '{"cmd":"get_file_info","path":"/tmp/a.txt","session_id":"s1"}'); + expectCommandJson(RemoteCommandFactory.readFileChunk('/tmp/a.txt', 12, 64, 's1'), '{"cmd":"read_file_chunk","path":"/tmp/a.txt","offset":12,"limit":64,"session_id":"s1"}'); + }); + }); + + describe('RemoteResponseMapper', () => { + it('maps session response items', 0, () => { + const sessions = RemoteResponseMapper.sessions([{ + session_id: 'session-abcdef', + name: 'Fallback title', + agent_type: 'agentic', + status: 'running', + updated_at: '2026-06-10T12:00:00.000Z', + created_at: '2026-06-09T12:00:00.000Z', + message_count: 4, + workspace_path: '/repo', + workspace_name: 'Repo' + }]); + expect(sessions.length).assertEqual(1); + expect(sessions[0].id).assertEqual('session-abcdef'); + expect(sessions[0].title).assertEqual('Fallback title'); + expect(sessions[0].agentType).assertEqual('agentic'); + expect(sessions[0].messageCount).assertEqual(4); + expect(sessions[0].workspaceName).assertEqual('Repo'); + }); + + it('maps alternate session time fields', 0, () => { + const sessions = RemoteResponseMapper.sessions([{ + session_id: 'session-time', + title: 'Alternate time fields', + last_message_at: 1781092800000, + createdAt: '2026-06-09 12:00:00', + message_count: 1 + }]); + expect(sessions.length).assertEqual(1); + expect(sessions[0].updatedAt).assertEqual('1781092800000'); + expect(sessions[0].createdAt).assertEqual('2026-06-09 12:00:00'); + }); + + it('maps assistant messages and nested tools', 0, () => { + const message = RemoteResponseMapper.chatMessage({ + id: 'm1', + role: 'assistant', + content: '', + thinking: 'Thinking out loud', + timestamp: '2026-06-10T12:00:00.000Z', + items: [{ + type: 'tool', + tool: { + id: 'tool-1', + name: 'shell', + status: 'pending' + }, + subItems: [{ + type: 'tool', + tool: { + id: 'tool-2', + name: 'edit', + status: 'done' + } + }] + }] + }); + expect(message.id).assertEqual('m1'); + expect(message.text).assertEqual(''); + expect(message.thinking || '').assertEqual('Thinking out loud'); + expect(message.status).assertEqual('done'); + expect(message.detail).assertEqual('shell · pending\nedit · done'); + expect(message.tools ? message.tools.length : 0).assertEqual(2); + }); + + it('uses fallback ids for message rendering keys', 0, () => { + const fromMessageId = RemoteResponseMapper.chatMessage({ + message_id: 'desktop-message-1', + role: 'assistant', + content: 'Mapped from alternate id', + timestamp: '2026-06-10T12:00:00.000Z' + }); + const generated = RemoteResponseMapper.chatMessage({ + role: 'user', + content: 'No id in response', + timestamp: '2026-06-10T12:01:00.000Z' + }); + + expect(fromMessageId.id).assertEqual('desktop-message-1'); + expect(generated.id.length > 0).assertTrue(); + expect(generated.id.indexOf('user-2026-06-10T12:01:00.000Z-')).assertEqual(0); + }); + + it('maps active turns with item fallback text and tool detail', 0, () => { + const message = RemoteResponseMapper.activeTurnToMessage({ + turn_id: 'turn-1', + status: 'active', + items: [{ + type: 'text', + content: 'Working on it' + }], + tools: [{ + id: 'tool-3', + name: 'read_file', + status: 'running' + }] + }); + expect(message.id).assertEqual('active-turn-1'); + expect(message.turnId || '').assertEqual('turn-1'); + expect(message.role).assertEqual('assistant'); + expect(message.text).assertEqual('Working on it'); + expect(message.status).assertEqual('active'); + expect(message.detail).assertEqual('read_file · running'); + }); + + it('does not promote subagent progress to active turn text', 0, () => { + const message = RemoteResponseMapper.activeTurnToMessage({ + turn_id: 'turn-structured', + status: 'active', + items: [{ + type: 'subagent', + is_subagent: true, + content: 'Subagent check', + subItems: [{ + type: 'text', + content: 'Nested progress' + }, { + type: 'tool', + tool: { + id: 'tool-nested-1', + name: 'inspect', + status: 'running' + } + }] + }] + }); + + expect(message.text).assertEqual(''); + expect(message.tools ? message.tools.length : 0).assertEqual(1); + expect(message.detail).assertEqual('inspect · running'); + }); + + it('does not use thinking as active turn final text', 0, () => { + const message = RemoteResponseMapper.activeTurnToMessage({ + turn_id: 'turn-thinking-only', + status: 'completed', + thinking: 'Still reasoning', + items: [{ + type: 'thinking', + content: 'Still reasoning' + }] + }); + + expect(message.text).assertEqual(''); + expect(message.thinking || '').assertEqual('Still reasoning'); + expect(message.status).assertEqual('completed'); + }); + }); +} diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index cf4fa967c0..0128521129 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -73,6 +73,7 @@ use bitfun_agent_runtime::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, }; +use bitfun_agent_runtime::sdk::PermissionReply; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_runtime_ports::{ AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, @@ -5151,6 +5152,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.tool_pipeline.cancel_tool(tool_id, reason).await } + pub async fn reply_to_tool(&self, tool_id: &str, reply: PermissionReply) -> BitFunResult<()> { + self.tool_pipeline.reply_to_tool(tool_id, reply).await + } + async fn get_subagent_concurrency_limiter(&self) -> SubagentConcurrencyLimiter { let configured = match GlobalConfigManager::get_service().await { Ok(config_service) => match config_service diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index b5d65fe69a..1ea522c139 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -18,6 +18,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::permission::{ PendingPermissionReceiver, PermissionRequestManager, PermissionWaitOutcome, }; +use bitfun_agent_runtime::sdk::PermissionReplySource; use bitfun_agent_stream::ToolArgumentRepairKind; use bitfun_agent_tools::{ build_invalid_tool_call_error_message, build_normal_tool_json_repair_notice, @@ -2135,6 +2136,26 @@ impl ToolPipeline { Ok(()) } + pub async fn reply_to_tool(&self, tool_id: &str, reply: PermissionReply) -> BitFunResult<()> { + let manager = self.permission_request_manager.as_ref().ok_or_else(|| { + BitFunError::service("Permission request manager is unavailable".to_string()) + })?; + let request = manager + .pending_requests() + .into_iter() + .find(|request| { + request.tool_call_id.as_deref() == Some(tool_id) || request.request_id == tool_id + }) + .ok_or_else(|| { + BitFunError::NotFound(format!("Permission request not found for tool: {tool_id}")) + })?; + manager + .reply(&request.request_id, reply, PermissionReplySource::User) + .await + .map(|_| ()) + .map_err(|error| BitFunError::service(error.to_string())) + } + /// Cancel all tools for a dialog turn pub async fn cancel_dialog_turn_tools(&self, dialog_turn_id: &str) -> BitFunResult<()> { info!( diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 1ceaa7ada0..c28de1c330 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -16,9 +16,9 @@ use bitfun_runtime_ports::{ AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSubmissionPort, AgentSubmissionSource, AgentThreadGoalManagementPort, AgentTurnCancellationPort, AgentTurnCancellationRequest, - RemoteControlStatePort, RemoteControlStateRequest, RemoteControlStateSnapshot, - RemoteSessionWorkspaceIdentity, RuntimeServiceCapability, RuntimeServicePort, - SessionStoragePathRequest, SessionStorePort, + PermissionPolicyPreset, RemoteControlStatePort, RemoteControlStateRequest, + RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, RuntimeServiceCapability, + RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, ToolPermissionConfig, }; use bitfun_services_integrations::remote_connect::{ agent_input_attachment_from_remote_image_context, build_remote_chat_messages, @@ -33,12 +33,12 @@ use bitfun_services_integrations::remote_connect::{ RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, - RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePollRuntimeHost, - RemoteReasoningModeFact, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, - RemoteSessionRuntimeHost, RemoteSessionStateTracker, RemoteSessionTrackerHost, - RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, - RemoteWorkspaceUpdate, + RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, + RemotePollRuntimeHost, RemoteReasoningModeFact, RemoteRecentWorkspaceFacts, + RemoteSessionMetadata, RemoteSessionRuntimeHost, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, + RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; use log::{debug, error, info}; use std::sync::Arc; @@ -1756,6 +1756,31 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { self.dispatcher.ensure_tracker(session_id) } + fn sync_pending_permissions(&self, session_id: &str, tracker: &RemoteSessionStateTracker) { + let Ok(manager) = crate::product_runtime::core_permission_request_manager() else { + return; + }; + for request in manager + .pending_requests() + .into_iter() + .filter(|request| request.session_id == session_id) + { + let tool_id = request + .tool_call_id + .clone() + .unwrap_or_else(|| request.request_id.clone()); + let tool_name = request.source.identity.clone(); + let tool_input = Some(serde_json::json!({ + "action": request.action, + "resources": request.resources, + })); + let input_preview = tool_input + .as_ref() + .and_then(|input| serde_json::to_string(input).ok()); + tracker.sync_pending_permission(tool_id, tool_name, input_preview, tool_input); + } + } + async fn load_model_catalog(&self, session_id: &str) -> Option { CoreServiceAgentRuntime::load_remote_model_catalog(Some(session_id)) .await @@ -1777,6 +1802,74 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { + async fn confirm_tool(&self, tool_id: &str) -> Result<(), String> { + self.coordinator()? + .reply_to_tool(tool_id, bitfun_agent_runtime::sdk::PermissionReply::Once) + .await + .map_err(|error| error.to_string()) + } + + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator()? + .reply_to_tool( + tool_id, + bitfun_agent_runtime::sdk::PermissionReply::Reject { + feedback: Some(reason), + }, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn get_permission_mode(&self) -> Result { + let service = crate::service::config::global::GlobalConfigManager::get_service() + .await + .map_err(|error| error.to_string())?; + let config: ToolPermissionConfig = service + .get_config(Some("tool_permissions")) + .await + .map_err(|error| error.to_string())?; + Ok(match config.policy.preset { + PermissionPolicyPreset::FullAccess => RemotePermissionMode::FullAccess, + PermissionPolicyPreset::Ask if config.interaction.auto_approve_ask => { + RemotePermissionMode::Auto + } + PermissionPolicyPreset::Ask => RemotePermissionMode::Ask, + }) + } + + async fn set_permission_mode( + &self, + mode: RemotePermissionMode, + ) -> Result { + let service = crate::service::config::global::GlobalConfigManager::get_service() + .await + .map_err(|error| error.to_string())?; + let mut config: ToolPermissionConfig = service + .get_config(Some("tool_permissions")) + .await + .map_err(|error| error.to_string())?; + match mode { + RemotePermissionMode::Ask => { + config.policy.preset = PermissionPolicyPreset::Ask; + config.interaction.auto_approve_ask = false; + } + RemotePermissionMode::Auto => { + config.policy.preset = PermissionPolicyPreset::Ask; + config.interaction.auto_approve_ask = true; + } + RemotePermissionMode::FullAccess => { + config.policy.preset = PermissionPolicyPreset::FullAccess; + config.interaction.auto_approve_ask = false; + } + } + service + .set_config("tool_permissions", &config) + .await + .map_err(|error| error.to_string())?; + Ok(mode) + } + async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { self.coordinator()? .cancel_tool(tool_id, reason) @@ -2099,13 +2192,19 @@ mod tests { } #[test] - fn core_service_agent_runtime_owner_skips_in_progress_remote_assistant_history() { + fn core_service_agent_runtime_owner_preserves_in_progress_remote_assistant_history() { let turn = remote_history_test_turn(TurnStatus::InProgress, None); let messages = remote_chat_messages_from_turns(&[turn]); - assert_eq!(messages.len(), 1); + assert_eq!(messages.len(), 2); assert_eq!(messages[0].role, "user"); + assert_eq!(messages[1].role, "assistant"); + assert_eq!(messages[1].content, "visible text"); + assert_eq!( + messages[1].tools.as_ref().unwrap()[0].status, + Some("running".to_string()) + ); } #[test] diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 8232e1f665..15b783bdf7 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -1402,6 +1402,9 @@ where #[async_trait::async_trait] pub trait RemotePollRuntimeHost: Send + Sync { fn ensure_tracker(&self, session_id: &str) -> Arc; + /// Hydrate permission requests that may have been registered before the + /// remote tracker observed the corresponding tool event. + fn sync_pending_permissions(&self, _session_id: &str, _tracker: &RemoteSessionStateTracker) {} async fn load_model_catalog(&self, session_id: &str) -> Option; async fn resolve_session_storage_dir(&self, session_id: &str) -> Option; async fn load_remote_chat_messages( @@ -1428,6 +1431,7 @@ where }; let tracker = host.ensure_tracker(session_id); + host.sync_pending_permissions(session_id, &tracker); let current_version = tracker.version(); let current_model_catalog = host.load_model_catalog(session_id).await; let model_catalog_delta = @@ -1475,6 +1479,13 @@ where #[async_trait::async_trait] pub trait RemoteInteractionRuntimeHost: Send + Sync { async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; + async fn confirm_tool(&self, tool_id: &str) -> Result<(), String>; + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; + async fn get_permission_mode(&self) -> Result; + async fn set_permission_mode( + &self, + mode: RemotePermissionMode, + ) -> Result; fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String>; } @@ -1486,6 +1497,30 @@ where H: RemoteInteractionRuntimeHost + ?Sized, { match command { + RemoteCommand::ConfirmTool { tool_id } => remote_interaction_accepted_response( + "confirm_tool", + tool_id.clone(), + host.confirm_tool(tool_id).await, + ), + RemoteCommand::RejectTool { tool_id, reason } => remote_interaction_accepted_response( + "reject_tool", + tool_id.clone(), + host.reject_tool( + tool_id, + reason + .clone() + .unwrap_or_else(|| "User rejected".to_string()), + ) + .await, + ), + RemoteCommand::GetPermissionMode => match host.get_permission_mode().await { + Ok(mode) => RemoteResponse::PermissionMode { mode }, + Err(message) => RemoteResponse::Error { message }, + }, + RemoteCommand::SetPermissionMode { mode } => match host.set_permission_mode(*mode).await { + Ok(mode) => RemoteResponse::PermissionMode { mode }, + Err(message) => RemoteResponse::Error { message }, + }, RemoteCommand::CancelTool { tool_id, reason } => { let cancel_reason = reason .clone() @@ -1698,6 +1733,7 @@ pub fn resolve_remote_agent_type(mobile_type: Option<&str>) -> &'static str { Some("code") | Some("agentic") | Some("Agentic") => "agentic", Some("multitask") | Some("Multitask") => "Multitask", Some("cowork") | Some("Cowork") => "Cowork", + Some("claw") | Some("Claw") | Some("assistant") | Some("chat") => "Claw", Some("plan") | Some("Plan") => "Plan", Some("debug") | Some("Debug") => "debug", _ => "agentic", @@ -1834,10 +1870,6 @@ pub fn build_remote_chat_messages(turns: Vec) -> Vec, sequence: usize, @@ -2022,6 +2054,14 @@ pub struct RemoteToolStatus { pub tool_input: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemotePermissionMode { + Ask, + Auto, + FullAccess, +} + /// Commands that remote clients can send to the desktop runtime. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] @@ -2092,6 +2132,17 @@ pub enum RemoteCommand { tool_id: String, reason: Option, }, + ConfirmTool { + tool_id: String, + }, + RejectTool { + tool_id: String, + reason: Option, + }, + GetPermissionMode, + SetPermissionMode { + mode: RemotePermissionMode, + }, AnswerQuestion { tool_id: String, answers: serde_json::Value, @@ -2283,6 +2334,9 @@ pub enum RemoteResponse { action: String, target_id: String, }, + PermissionMode { + mode: RemotePermissionMode, + }, FileContent { name: String, content_base64: String, @@ -2413,9 +2467,12 @@ where | RemoteCommand::ReadFileChunk { .. } | RemoteCommand::GetFileInfo { .. } => host.handle_workspace_file_command(command).await, - RemoteCommand::CancelTool { .. } | RemoteCommand::AnswerQuestion { .. } => { - host.handle_interaction_command(command).await - } + RemoteCommand::ConfirmTool { .. } + | RemoteCommand::RejectTool { .. } + | RemoteCommand::GetPermissionMode + | RemoteCommand::SetPermissionMode { .. } + | RemoteCommand::CancelTool { .. } + | RemoteCommand::AnswerQuestion { .. } => host.handle_interaction_command(command).await, RemoteCommand::SendMessage { session_id, @@ -2655,6 +2712,39 @@ impl RemoteSessionStateTracker { self.bump_version(); } + pub fn sync_pending_permission( + &self, + tool_id: String, + tool_name: String, + input_preview: Option, + tool_input: Option, + ) { + let mut state = self.state.write().unwrap(); + if state.turn_id.is_none() { + return; + } + let already_pending = state + .active_tools + .iter() + .any(|tool| tool.id == tool_id && tool.status == "pending_confirmation"); + if already_pending { + return; + } + Self::upsert_active_tool( + &mut state, + &tool_id, + &tool_name, + "pending_confirmation", + input_preview, + tool_input, + false, + ); + state.session_state = "running".to_string(); + state.turn_status = "active".to_string(); + drop(state); + self.bump_version(); + } + pub fn finalize_completed_turn(&self) { let mut state = self.state.write().unwrap(); if matches!( @@ -3693,6 +3783,25 @@ mod tests { #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for FakeInteractionHost { + async fn confirm_tool(&self, _tool_id: &str) -> Result<(), String> { + Ok(()) + } + + async fn reject_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { + Ok(()) + } + + async fn get_permission_mode(&self) -> Result { + Ok(RemotePermissionMode::Ask) + } + + async fn set_permission_mode( + &self, + mode: RemotePermissionMode, + ) -> Result { + Ok(mode) + } + async fn cancel_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { Ok(()) }