diff --git a/PhoneClaw/Agent/AgentEngine.swift b/Agent/AgentEngine.swift similarity index 100% rename from PhoneClaw/Agent/AgentEngine.swift rename to Agent/AgentEngine.swift diff --git a/PhoneClaw/App/PhoneClawApp.swift b/App/PhoneClawApp.swift similarity index 100% rename from PhoneClaw/App/PhoneClawApp.swift rename to App/PhoneClawApp.swift diff --git a/PhoneClaw/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png similarity index 100% rename from PhoneClaw/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png rename to Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png diff --git a/PhoneClaw/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from PhoneClaw/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/PhoneClaw/Assets.xcassets/Contents.json b/Assets.xcassets/Contents.json similarity index 100% rename from PhoneClaw/Assets.xcassets/Contents.json rename to Assets.xcassets/Contents.json diff --git a/PhoneClaw/Info.plist b/Info.plist similarity index 100% rename from PhoneClaw/Info.plist rename to Info.plist diff --git a/PhoneClaw/LLM/LLMEngine.swift b/LLM/LLMEngine.swift similarity index 100% rename from PhoneClaw/LLM/LLMEngine.swift rename to LLM/LLMEngine.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4Config.swift b/LLM/MLX/Gemma4Config.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4Config.swift rename to LLM/MLX/Gemma4Config.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4LanguageModel.swift b/LLM/MLX/Gemma4LanguageModel.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4LanguageModel.swift rename to LLM/MLX/Gemma4LanguageModel.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4Model.swift b/LLM/MLX/Gemma4Model.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4Model.swift rename to LLM/MLX/Gemma4Model.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4Processor.swift b/LLM/MLX/Gemma4Processor.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4Processor.swift rename to LLM/MLX/Gemma4Processor.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4Registration.swift b/LLM/MLX/Gemma4Registration.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4Registration.swift rename to LLM/MLX/Gemma4Registration.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4RoPE.swift b/LLM/MLX/Gemma4RoPE.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4RoPE.swift rename to LLM/MLX/Gemma4RoPE.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4TextModel.swift b/LLM/MLX/Gemma4TextModel.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4TextModel.swift rename to LLM/MLX/Gemma4TextModel.swift diff --git a/PhoneClaw/LLM/MLX/Gemma4VisionModel.swift b/LLM/MLX/Gemma4VisionModel.swift similarity index 100% rename from PhoneClaw/LLM/MLX/Gemma4VisionModel.swift rename to LLM/MLX/Gemma4VisionModel.swift diff --git a/PhoneClaw/LLM/MLX/MLXTokenizersLoader.swift b/LLM/MLX/MLXTokenizersLoader.swift similarity index 100% rename from PhoneClaw/LLM/MLX/MLXTokenizersLoader.swift rename to LLM/MLX/MLXTokenizersLoader.swift diff --git a/PhoneClaw/LLM/MLXLocalLLMService.swift b/LLM/MLXLocalLLMService.swift similarity index 100% rename from PhoneClaw/LLM/MLXLocalLLMService.swift rename to LLM/MLXLocalLLMService.swift diff --git a/PhoneClaw/LLM/PromptBuilder.swift b/LLM/PromptBuilder.swift similarity index 100% rename from PhoneClaw/LLM/PromptBuilder.swift rename to LLM/PromptBuilder.swift diff --git a/LiteRT-LM b/LiteRT-LM new file mode 160000 index 0000000..24a8bb1 --- /dev/null +++ b/LiteRT-LM @@ -0,0 +1 @@ +Subproject commit 24a8bb189f9c0af1e3108165a07315cb7ac0d5e4 diff --git a/PhoneClaw/PhoneClaw.entitlements b/PhoneClaw.entitlements similarity index 100% rename from PhoneClaw/PhoneClaw.entitlements rename to PhoneClaw.entitlements diff --git a/PhoneClaw.xcodeproj/project.pbxproj b/PhoneClaw.xcodeproj/project.pbxproj index d62dc08..c8dbbd7 100644 --- a/PhoneClaw.xcodeproj/project.pbxproj +++ b/PhoneClaw.xcodeproj/project.pbxproj @@ -50,7 +50,7 @@ 4DD9808FE93325C3CE4CFA00 /* PhoneClawApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneClawApp.swift; sourceTree = ""; }; 52F30C6348B834E5D157205F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 571908036E6DCED1D58C1E9E /* Skills.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Skills.swift; sourceTree = ""; }; - 772CB5029014926E172C0BF1 /* LocalLLMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLLMService.swift; sourceTree = ""; }; + AA2000000000000000000001 /* LLMEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMEngine.swift; sourceTree = ""; }; AA2000000000000000000002 /* MLXLocalLLMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXLocalLLMService.swift; sourceTree = ""; }; AA2000000000000000000003 /* Gemma4Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4Config.swift; sourceTree = ""; }; @@ -146,7 +146,6 @@ AA2000000000000000000001 /* LLMEngine.swift */, AA3000000000000000000001 /* MLX */, AA2000000000000000000002 /* MLXLocalLLMService.swift */, - 772CB5029014926E172C0BF1 /* LocalLLMService.swift */, 2E7C06DA449D1395217A42D5 /* PromptBuilder.swift */, ); path = LLM; @@ -180,7 +179,7 @@ 98EFD7CCA7E78715F07BABE3 /* Skills */, EF4D07A808616D6F13771DBE /* UI */, ); - path = PhoneClaw; + name = PhoneClaw; sourceTree = ""; }; E0660A5AE3C683B7B20600EE /* Agent */ = { @@ -492,11 +491,11 @@ baseConfigurationReference = 098B30935B91CD0BEF3A5056 /* Pods-PhoneClaw.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = PhoneClaw/PhoneClaw.entitlements; + CODE_SIGN_ENTITLEMENTS = PhoneClaw.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZP4QNJ35TH; - INFOPLIST_FILE = PhoneClaw/Info.plist; + INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -504,7 +503,6 @@ MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.zxw.phoneclaw2026; SDKROOT = iphoneos; - SWIFT_OBJC_BRIDGING_HEADER = "PhoneClaw/PhoneClaw-Bridging-Header.h"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -514,11 +512,11 @@ baseConfigurationReference = A1AB7F250BEEFF938318AA75 /* Pods-PhoneClaw.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = PhoneClaw/PhoneClaw.entitlements; + CODE_SIGN_ENTITLEMENTS = PhoneClaw.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZP4QNJ35TH; - INFOPLIST_FILE = PhoneClaw/Info.plist; + INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -526,7 +524,6 @@ MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.zxw.phoneclaw2026; SDKROOT = iphoneos; - SWIFT_OBJC_BRIDGING_HEADER = "PhoneClaw/PhoneClaw-Bridging-Header.h"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; diff --git a/PhoneClaw/LLM/LocalLLMService.swift b/PhoneClaw/LLM/LocalLLMService.swift deleted file mode 100644 index 89c6521..0000000 --- a/PhoneClaw/LLM/LocalLLMService.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation -import MediaPipeTasksGenAI - -// MARK: - 使用 MediaPipe 高级 Swift API(和 Google Gallery 相同) - -@Observable -class LocalLLMService { - - private var inference: LlmInference? - private var currentSession: LlmInference.Session? - var isLoaded = false - var isGenerating = false - var statusMessage = "等待加载模型..." - - // MARK: - 后端与采样参数(AgentEngine 可动态修改) - var useGPU: Bool = false // 需要 extended-virtual-addressing entitlement 才能开启 - var samplingTopK: Int = 40 - var samplingTopP: Float = 0.95 - var samplingTemperature: Float = 0.8 - var maxOutputTokens: Int = 2048 - - static let modelFileName = "gemma-4-E4B-it" - static let modelFileExtension = "litertlm" - - deinit { - currentSession = nil - inference = nil - } - - // MARK: - 加载模型 - - func loadModel() { - DispatchQueue.main.async { self.statusMessage = "正在加载 E4B 模型..." } - - var modelPath = Bundle.main.path(forResource: Self.modelFileName, ofType: Self.modelFileExtension) - if modelPath == nil { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let docFile = docs.appendingPathComponent("\(Self.modelFileName).\(Self.modelFileExtension)") - if FileManager.default.fileExists(atPath: docFile.path) { - modelPath = docFile.path - } - } - guard let path = modelPath else { - DispatchQueue.main.async { self.statusMessage = "❌ 模型文件不存在" } - print("[LLM] 找不到模型") - return - } - - print("[LLM] 开始加载: \(path)") - let startTime = CFAbsoluteTimeGetCurrent() - - do { - let options = LlmInference.Options(modelPath: path) - // GPU 需要 extended-virtual-addressing entitlement,否则 E4B 模型会 OOM - // 获得 entitlement 后改为 .gpu 即可启用 Metal 加速 - options.preferredBackend = useGPU ? .gpu : .cpu - options.maxTokens = 512 - options.maxTopk = useGPU ? 40 : 1 - options.waitForWeightUploads = false - options.sequenceBatchSize = 0 - - let label = useGPU ? "GPU" : "CPU" - print("[LLM] 使用 \(label) 后端加载...") - let llm = try LlmInference(options: options) - self.inference = llm - - let elapsed = CFAbsoluteTimeGetCurrent() - startTime - DispatchQueue.main.async { - self.isLoaded = true - self.statusMessage = "\(label) 模型已就绪 ✅" - } - print("[LLM] Engine 创建成功 (\(label)),耗时: \(String(format: "%.1f", elapsed))秒") - } catch { - DispatchQueue.main.async { self.statusMessage = "❌ \(error.localizedDescription)" } - print("[LLM] Engine 创建失败: \(error)") - } - } - - // MARK: - Session 管理 - - private func createSession() -> Bool { - currentSession = nil - guard let llm = inference else { return false } - - do { - let sessionOptions = LlmInference.Session.Options() - sessionOptions.topk = min(samplingTopK, useGPU ? 40 : 1) // 不能超过 maxTopk - sessionOptions.topp = samplingTopP - sessionOptions.temperature = samplingTemperature - sessionOptions.randomSeed = 0 - - self.currentSession = try LlmInference.Session(llmInference: llm, options: sessionOptions) - return true - } catch { - print("[LLM] Session 创建失败: \(error)") - return false - } - } - - // MARK: - 流式生成 - - func generateStream(prompt: String, onToken: @escaping (String) -> Void, onComplete: @escaping (Result) -> Void) { - guard inference != nil else { - onComplete(.failure(LLMError.notLoaded)) - return - } - - DispatchQueue.main.async { self.isGenerating = true } - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - - guard self.createSession(), let sess = self.currentSession else { - DispatchQueue.main.async { - self.isGenerating = false - onComplete(.failure(LLMError.sessionFailed)) - } - return - } - - do { - try sess.addQueryChunk(inputText: prompt) - } catch { - print("[LLM] AddQueryChunk 失败: \(error)") - DispatchQueue.main.async { - self.isGenerating = false - onComplete(.failure(LLMError.addChunkFailed)) - } - return - } - - var fullResponse = "" - - do { - try sess.generateResponseAsync { [weak self] partialResponse, error in - // progress callback — 每个 token - if let err = error { - print("[LLM] 流式错误: \(err)") - return - } - if let token = partialResponse { - fullResponse += token - DispatchQueue.main.async { - onToken(token) - } - } - } completion: { [weak self] in - // 完成回调 - print("[LLM] 流式完成,长度: \(fullResponse.count)") - DispatchQueue.main.async { - self?.isGenerating = false - onComplete(.success(fullResponse)) - } - } - } catch { - print("[LLM] GenerateResponseAsync 失败: \(error)") - DispatchQueue.main.async { - self.isGenerating = false - onComplete(.failure(LLMError.predictFailed)) - } - } - } - } - - // MARK: - 同步生成(备用) - - func generate(prompt: String) throws -> String { - guard inference != nil else { throw LLMError.notLoaded } - guard createSession(), let sess = currentSession else { throw LLMError.sessionFailed } - - try sess.addQueryChunk(inputText: prompt) - let response = try sess.generateResponse() - return response - } - - enum LLMError: LocalizedError { - case notLoaded, sessionFailed, addChunkFailed, predictFailed - var errorDescription: String? { - switch self { - case .notLoaded: return "模型尚未加载" - case .sessionFailed: return "Session 创建失败" - case .addChunkFailed: return "Prompt 添加失败" - case .predictFailed: return "推理失败" - } - } - } -} diff --git a/PhoneClaw/PhoneClaw-Bridging-Header.h b/PhoneClaw/PhoneClaw-Bridging-Header.h deleted file mode 100644 index df05dc1..0000000 --- a/PhoneClaw/PhoneClaw-Bridging-Header.h +++ /dev/null @@ -1,6 +0,0 @@ -// -// PhoneClaw-Bridging-Header.h -// 直接调用 LiteRT-LM C API -// - -#include "llm_inference_engine.h" diff --git a/PhoneClaw/llm_inference_engine.h b/PhoneClaw/llm_inference_engine.h deleted file mode 100755 index cd4902d..0000000 --- a/PhoneClaw/llm_inference_engine.h +++ /dev/null @@ -1,345 +0,0 @@ -// Copyright 2024 The ODML Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#ifndef THIRD_PARTY_ODML_INFRA_GENAI_INFERENCE_C_LLM_INFERENCE_ENGINE_H_ -#define THIRD_PARTY_ODML_INFRA_GENAI_INFERENCE_C_LLM_INFERENCE_ENGINE_H_ - -#ifdef __cplusplus -#include -#else -#include -#include -#include -#endif - -#ifdef __EMSCRIPTEN__ -#include -#endif // __EMSCRIPTEN__ - -#ifndef ODML_EXPORT -#define ODML_EXPORT __attribute__((visibility("default"))) -#endif // ODML_EXPORT - -#ifdef __cplusplus -extern "C" { -#endif - -typedef void LlmInferenceEngine_Engine; - -typedef void LlmInferenceEngine_Session; - -typedef void SentencePieceProcessor; - -typedef void Constraint; - -// LlmActivationDataType defines the activation data type for the model. -typedef enum { - // Use Default activation data type mentioned in the model metadata file. - kLlmActivationDataTypeDefault = 0, - - // Use Float32 activation data type. - kLlmActivationDataTypeFloat32 = 1, - - // Use Float16 activation data type. - kLlmActivationDataTypeFloat16 = 2, - - // Use Int16 activation data type. - kLlmActivationDataTypeInt16 = 3, - - // Use Int8 activation data type. - kLlmActivationDataTypeInt8 = 4, -} LlmActivationDataType; - -// Specify the LiteRT backend to use for the LLM model. If not specified, the -// default backend will be used. -typedef enum { - // Use default backend extracted from the model. - kLlmPreferredBackendDefault = 0, - - // Use GPU backend. - kLlmPreferredBackendGpu = 1, - - // Use CPU backend. - kLlmPreferredBackendCpu = 2, -} LlmPreferredBackend; - -// LlmSessionConfig configures how to execute the model. -// LINT.IfChange -typedef struct { - // Path to the model artifact. - const char* model_path; - -#ifdef __EMSCRIPTEN__ - // Web only supports the following model loading methods: - // * File System loading for converted LLMs. model_path has to be set and its - // name has to end with .task. - // * Stream loading for handwritten LLMs. read_model_fn has to be set. - - // Function to read model file. - // The function returns a pointer to heap memory that contains the model file - // contents started from `offset` with `size`. - // Since the model file is hosted on JavaScript layer and this function copies - // the data to the heap memory, the `mode` instructs how the source model file - // data should be manipulated: - // 0: Data will be kept in memory after read. - // 1: Data will not be accessed again and can be discarded. - // 2: All data has been used and can be discarded. - using ReadDataFn = - std::function; - ReadDataFn* read_model_fn; -#endif // __EMSCRIPTEN__ - - // Path to the vision encoder to use for vision modality. Optional. - const char* vision_encoder_path; - - // Path to the vision adapter to use for vision modality. Optional. - const char* vision_adapter_path; - - // Directory path for storing model related tokenizer and cache weights. the - // user is responsible for providing the directory that can be writable by the - // program. - const char* cache_dir; - - // Maximum number of tokens for input and output. - size_t max_num_tokens; - - // Maximum number of images to be used for vision modality. - size_t max_num_images; - - // Number of decode steps per sync. Used by GPU only. The default value is 3. - size_t num_decode_steps_per_sync; - - // Sequence batch size for encoding. Used by GPU only. Number of input tokens - // to process at a time for batch processing. Setting this value to 1 means - // both the encoding and decoding share the same graph of sequence length - // of 1. Setting this value to 0 means the batch size will be optimized - // programmatically. - size_t sequence_batch_size; - - // Number of supported lora ranks for the base model. Used by GPU only. - size_t number_of_supported_lora_ranks; - - // The supported lora ranks for the base model. Used by GPU only. - size_t* supported_lora_ranks; - - // Maximum top k, which is the max Top-K value supported for all - // sessions created with the engine, used by GPU only. If a session with Top-K - // value larger than this is being asked to be created, it will be - // rejected(throw error). If not provided, the max top k will be 1, which - // means only greedy decoding is supported for any sessions created with this - // engine. - size_t max_top_k; - - // Optional setting for specific activation data type. - LlmActivationDataType llm_activation_data_type; - - // Optional setting for the number of draft tokens to generate when using - // speculative decoding. Setting to 0 will disable speculative decoding. - size_t num_draft_tokens; - - // If true, waits for weights to finish uploading when initializing. Otherwise - // initialization may finish before weights have finished uploading which - // might push some of the weight upload time into input processing. - bool wait_for_weight_uploads; - - // Whether the submodel should be used if available. - bool use_submodel; - - // Optional setting to prefer specific backend instead. - LlmPreferredBackend preferred_backend; - - // Whether to pass the audio modality settings. - bool enable_audio_modality; - // Maximum audio sequence length. - size_t max_audio_sequence_length; -} LlmModelSettings; -// LINT.ThenChange(//depot/google3/third_party/mediapipe/tasks/java/com/google/mediapipe/tasks/genai/llminference/jni/llm.cc) - -// LlmPromptTemplates defines the prompt templates for the session. -typedef struct { - // The prompt prefix for the user role. - const char* user_prefix; - - // The prompt suffix for the user role. - const char* user_suffix; - - // The prompt prefix for the model role. - const char* model_prefix; - - // The prompt suffix for the model role. - const char* model_suffix; - - // The prompt prefix for the system role. - const char* system_prefix; - - // The prompt suffix for the system role. - const char* system_suffix; -} LlmPromptTemplates; - -// LlmSessionConfig configures how to execute the model. -typedef struct { - // Top K number of tokens to be sampled from for each decoding step. - size_t topk; - - // Maximum cumulative probability over the tokens to sample from in each - // decoding step for top-p / nucleus sampling. - float topp; - - // Randomness when decoding the next token, 0.0f means greedy decoding. - float temperature; - - // random seed, for reproducible sampling. - size_t random_seed; - - // Path to the LoRA tflite flatbuffer file. Optional. - // This is only compatible with GPU handwritten models and converter based - // models. - const char* lora_path; - - // Whether to configure the graph to include the token cost calculator, - // which allows users to only compute the cost of a prompt. - bool include_token_cost_calculator; - - // Whether to configure the graph to include the vision modality. - bool enable_vision_modality; - - // Whether to configure the graph to include the audio modality. - bool enable_audio_modality; - - // Prompt templates to use for the session. - // If not provided, the default prompt templates will be used. - const LlmPromptTemplates* prompt_templates; -} LlmSessionConfig; - -// The config used to update the runtime behavior of the session. -typedef struct { - // Top K number of tokens to be sampled from for each decoding step. - size_t* topk; - - // Maximum cumulative probability over the tokens to sample from in each - // decoding step for top-p / nucleus sampling. - float* topp; - - // Randomness when decoding the next token, 0.0f means greedy decoding. - float* temperature; - - // random seed, for reproducible sampling. - size_t* random_seed; - - // The constraint to use for the session. - Constraint* constraint; -} SessionRuntimeConfig; - -// LlmResponseContext is the return type for -// LlmInferenceEngine_Session_PredictSync. -typedef struct { - // An array of strings. The size of the array depends on the number of - // responses. - char** response_array; - - // Number of responses. - int response_count; - - // Done all outputs for this session. - bool done; -} LlmResponseContext; - -// Frees all context within the LlmResponseContext. -ODML_EXPORT void LlmInferenceEngine_CloseResponseContext( - LlmResponseContext* response_context); - -// Create a LlmInferenceEngine session for executing a query. -ODML_EXPORT int LlmInferenceEngine_CreateEngine( - const LlmModelSettings* model_settings, - LlmInferenceEngine_Engine** engine_out, char** error_msg); - -// Returns the SentencePieceProcessor handle used by the engine. -ODML_EXPORT int LlmInferenceEngine_GetSentencePieceProcessor( - LlmInferenceEngine_Engine* engine, - const SentencePieceProcessor** processor_out, char** error_msg); - -// Free the engine, will release ownership of resource held by the engine. -// Resource might be freed if no sessions are referencing to it. -ODML_EXPORT void LlmInferenceEngine_Engine_Delete( - LlmInferenceEngine_Engine* engine); - -// Create a LlmInferenceEngine session for executing a query. -ODML_EXPORT int LlmInferenceEngine_CreateSession( - LlmInferenceEngine_Engine* engine, const LlmSessionConfig* session_config, - LlmInferenceEngine_Session** session_out, char** error_msg); - -// Update the runtime config for the session. -ODML_EXPORT int LlmInferenceEngine_UpdateRuntimeConfig( - LlmInferenceEngine_Session* session, - const SessionRuntimeConfig* runtime_config, char** error_msg); - -// Free the session, will wait until graph is done executing. -ODML_EXPORT int LlmInferenceEngine_Session_Delete( - LlmInferenceEngine_Session* session); - -// Add query chunk to the session. This can be called multiple times to add -// multiple query chunks before calling `PredictSync` or `PredictAsync`. The -// query chunks will be processed in the order they are added, similar to a -// concatenated prompt, but able to be processed in chunks. -ODML_EXPORT int LlmInferenceEngine_Session_AddQueryChunk( - LlmInferenceEngine_Session* session, const char* input, char** error_msg); - -// Adds an SKBitmap to the session. -ODML_EXPORT int LlmInferenceEngine_Session_AddImage( - LlmInferenceEngine_Session* session, const void* sk_bitmap, - char** error_msg); - -// Return the generated output based on the previously added query chunks in -// sync mode. -ODML_EXPORT int LlmInferenceEngine_Session_PredictSync( - LlmInferenceEngine_Session* session, LlmResponseContext* response_context, - char** error_msg); - -// Run callback function in async mode. -// The callback will be invoked multiple times until `response_context.done` -// is `true`. You need to invoke `LlmInferenceEngine_CloseResponseContext` after -// each invocation to free memory. -// The callback context can be a pointer to any user defined data structure as -// it is passed to the callback unmodified. -ODML_EXPORT int LlmInferenceEngine_Session_PredictAsync( - LlmInferenceEngine_Session* session, void* callback_context, - char** error_msg, - void (*callback)(void* callback_context, - LlmResponseContext* response_context)); - -// Request cancellation for pending processes. -ODML_EXPORT int LlmInferenceEngine_Session_PendingProcessCancellation( - LlmInferenceEngine_Session* session, char** error_msg); - -// Clone the provided session. -ODML_EXPORT int LlmInferenceEngine_Session_Clone( - LlmInferenceEngine_Session* session, - LlmInferenceEngine_Session** cloned_session, char** error_msg); - -// Tokenizes an input prompt using a pre-existing processor and returns its -// length in tokens. Returns -1 if tokenization fails. -ODML_EXPORT int LlmInferenceEngine_Session_SizeInTokens( - LlmInferenceEngine_Session* session, const char* input, char** error_msg); - -// Adds an audio to the session. -// The audio_bytes is expected to be the raw data of a mono .wav file. -ODML_EXPORT int LlmInferenceEngine_Session_AddAudio( - LlmInferenceEngine_Session* session, const char* audio_bytes, - int audio_bytes_size, char** error_msg); - -#ifdef __cplusplus -} // extern C -#endif - -#endif // THIRD_PARTY_ODML_INFRA_GENAI_INFERENCE_C_LLM_INFERENCE_ENGINE_H_ diff --git a/PhoneClawMac/Package.swift b/PhoneClawMac/Package.swift deleted file mode 100644 index 2a78738..0000000 --- a/PhoneClawMac/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "PhoneClawMac", - platforms: [.macOS(.v14)], - products: [ - .executable(name: "PhoneClawMac", targets: ["PhoneClawMac"]), - ], - dependencies: [ - .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.0"), - ], - targets: [ - .systemLibrary( - name: "CLiteRtLm", - path: "Sources/CLiteRtLm", - pkgConfig: nil, - providers: nil - ), - .executableTarget( - name: "PhoneClawMac", - dependencies: ["CLiteRtLm", "Yams"], - path: "Sources/PhoneClawMac", - linkerSettings: [ - .unsafeFlags([ - "-L", ".", - "-Xlinker", "-rpath", "-Xlinker", "@executable_path", - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/../lib", - ]), - ] - ), - ] -) diff --git a/PhoneClawMac/Sources/CLiteRtLm/include/engine.h b/PhoneClawMac/Sources/CLiteRtLm/include/engine.h deleted file mode 100644 index 6bfbe7b..0000000 --- a/PhoneClawMac/Sources/CLiteRtLm/include/engine.h +++ /dev/null @@ -1,502 +0,0 @@ -// Copyright 2025 The ODML Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#ifndef THIRD_PARTY_ODML_LITERT_LM_C_ENGINE_H_ -#define THIRD_PARTY_ODML_LITERT_LM_C_ENGINE_H_ - -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// For Windows, __declspec( dllexport ) is required to export function in .dll. -// https://learn.microsoft.com/en-us/cpp/cpp/using-dllimport-and-dllexport-in-cpp-classes?view=msvc-170 -// -// _WIN32 is defined as 1 when the compilation target is 32-bit ARM, 64-bit ARM, -// x86, x64, or ARM64EC. Otherwise, undefined. -// https://learn.microsoft.com/en-us/cpp/preprocessor/predefined-macros -#if defined(_WIN32) -#define LITERT_LM_C_API_EXPORT __declspec(dllexport) -#else -#define LITERT_LM_C_API_EXPORT -#endif - -// Opaque pointer for the LiteRT LM Engine. -typedef struct LiteRtLmEngine LiteRtLmEngine; - -// Opaque pointer for the LiteRT LM Session. -typedef struct LiteRtLmSession LiteRtLmSession; - -// Opaque pointer for the LiteRT LM Responses. -typedef struct LiteRtLmResponses LiteRtLmResponses; - -// Opaque pointer for the LiteRT LM Engine Settings. -typedef struct LiteRtLmEngineSettings LiteRtLmEngineSettings; - -// Opaque pointer for the LiteRT LM Benchmark Info. -typedef struct LiteRtLmBenchmarkInfo LiteRtLmBenchmarkInfo; - -// Opaque pointer for the LiteRT LM Conversation. -typedef struct LiteRtLmConversation LiteRtLmConversation; - -// Opaque pointer for a JSON response. -typedef struct LiteRtLmJsonResponse LiteRtLmJsonResponse; - -// Opaque pointer for LiteRT LM Session Config. -typedef struct LiteRtLmSessionConfig LiteRtLmSessionConfig; - -// Opaque pointer for LiteRT LM Conversation Config. -typedef struct LiteRtLmConversationConfig LiteRtLmConversationConfig; - -// Represents the type of sampler. -typedef enum { - kTypeUnspecified = 0, - // Probabilistically pick among the top k tokens. - kTopK = 1, - // Probabilistically pick among the tokens such that the sum is greater - // than or equal to p tokens after first performing top-k sampling. - kTopP = 2, - // Pick the token with maximum logit (i.e., argmax). - kGreedy = 3, -} Type; - -// Parameters for the sampler. -typedef struct { - Type type; - int32_t top_k; - float top_p; - float temperature; - int32_t seed; -} LiteRtLmSamplerParams; - -// Creates a LiteRT LM Session Config. -// The caller is responsible for destroying the config using -// `litert_lm_session_config_delete`. -// @return A pointer to the created config, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmSessionConfig* litert_lm_session_config_create(); - -// Sets the maximum number of output tokens per decode step for this session. -// @param config The config to modify. -// @param max_output_tokens The maximum number of output tokens. -LITERT_LM_C_API_EXPORT -void litert_lm_session_config_set_max_output_tokens( - LiteRtLmSessionConfig* config, int max_output_tokens); - -// Sets the sampler parameters for this session config. -// @param config The config to modify. -// @param sampler_params The sampler parameters to use. -LITERT_LM_C_API_EXPORT -void litert_lm_session_config_set_sampler_params( - LiteRtLmSessionConfig* config, const LiteRtLmSamplerParams* sampler_params); - -// Destroys a LiteRT LM Session Config. -// @param config The config to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_session_config_delete(LiteRtLmSessionConfig* config); - -// Creates a LiteRT LM Conversation Config. -// The caller is responsible for destroying the config using -// `litert_lm_conversation_config_delete`. -// @param engine The engine to use. -// @param session_config The session config to use. If NULL, default -// session config will be used. -// @param system_message_json The system message in JSON format. -// @param tools_json The tools description in JSON array format. -// @param enable_constrained_decoding Whether to enable constrained decoding. -// @return A pointer to the created config, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmConversationConfig* litert_lm_conversation_config_create( - LiteRtLmEngine* engine, const LiteRtLmSessionConfig* session_config, - const char* system_message_json, const char* tools_json, - const char* messages_json, bool enable_constrained_decoding); - -// Destroys a LiteRT LM Conversation Config. -// @param config The config to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_conversation_config_delete(LiteRtLmConversationConfig* config); - -// Sets the minimum log level for the LiteRT LM library. -// Log levels are: 0=INFO, 1=WARNING, 2=ERROR, 3=FATAL. -LITERT_LM_C_API_EXPORT -void litert_lm_set_min_log_level(int level); - -// Represents the type of input data. -typedef enum { - kInputText, - kInputImage, - kInputImageEnd, - kInputAudio, - kInputAudioEnd, -} InputDataType; - -// Represents a single piece of input data. -typedef struct { - InputDataType type; - // The data pointer. The interpretation depends on the `type`. - // For kInputText, it's a UTF-8 string. - // For kInputImage and kInputAudio, it's a pointer to the raw bytes. - const void* data; - // The size of the data in bytes. - size_t size; -} InputData; - -// Creates LiteRT LM Engine Settings. The caller is responsible for destroying -// the settings using `litert_lm_engine_settings_delete`. -// -// @param model_path The path to the model file. -// @param backend_str The backend to use (e.g., "cpu", "gpu"). -// @param vision_backend_str The vision backend to use, or NULL if not set. -// @param audio_backend_str The audio backend to use, or NULL if not set. -// @return A pointer to the created settings, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmEngineSettings* litert_lm_engine_settings_create( - const char* model_path, const char* backend_str, - const char* vision_backend_str, const char* audio_backend_str); - -// Destroys LiteRT LM Engine Settings. -// -// @param settings The settings to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_delete(LiteRtLmEngineSettings* settings); - -// Sets the maximum number of tokens for the engine. -// -// @param settings The engine settings. -// @param max_num_tokens The maximum number of tokens. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_set_max_num_tokens( - LiteRtLmEngineSettings* settings, int max_num_tokens); - -// Sets the cache directory for the engine. -// -// @param settings The engine settings. -// @param cache_dir The cache directory. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_set_cache_dir(LiteRtLmEngineSettings* settings, - const char* cache_dir); - -// Sets the activation data type. -// -// @param settings The engine settings. -// @param activation_data_type_int The activation data type. See -// `ActivationDataType` in executor_settings_base.h for the possible values -// (e.g., 0 for F32, 1 for F16, 2 for I16, 3 for I8). -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_set_activation_data_type( - LiteRtLmEngineSettings* settings, int activation_data_type_int); - -// Sets the prefill chunk size for the engine. Only applicable for CPU backend -// with dynamic models. -// -// @param settings The engine settings. -// @param prefill_chunk_size The prefill chunk size. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_set_prefill_chunk_size( - LiteRtLmEngineSettings* settings, int prefill_chunk_size); - -// Enables benchmarking for the engine. -// -// @param settings The engine settings. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_enable_benchmark( - LiteRtLmEngineSettings* settings); - -// Sets the number of prefill tokens for benchmarking. -// -// @param settings The engine settings. -// @param num_prefill_tokens The number of prefill tokens. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_set_num_prefill_tokens( - LiteRtLmEngineSettings* settings, int num_prefill_tokens); - -// Sets the number of decode tokens for benchmarking. -// -// @param settings The engine settings. -// @param num_decode_tokens The number of decode tokens. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_settings_set_num_decode_tokens( - LiteRtLmEngineSettings* settings, int num_decode_tokens); - -// Creates a LiteRT LM Engine from the given settings. The caller is responsible -// for destroying the engine using `litert_lm_engine_delete`. -// -// @param settings The engine settings. -// @return A pointer to the created engine, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmEngine* litert_lm_engine_create(const LiteRtLmEngineSettings* settings); - -// Destroys a LiteRT LM Engine. -// -// @param engine The engine to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_engine_delete(LiteRtLmEngine* engine); - -// Creates a LiteRT LM Session. The caller is responsible for destroying the -// session using `litert_lm_session_delete`. -// -// @param engine The engine to create the session from. -// @param config The session config of the session. If NULL, use the default -// session config. -// @return A pointer to the created session, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmSession* litert_lm_engine_create_session(LiteRtLmEngine* engine, - LiteRtLmSessionConfig* config); - -// Destroys a LiteRT LM Session. -// -// @param session The session to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_session_delete(LiteRtLmSession* session); - -// Generates content from the input prompt. -// -// @param session The session to use for generation. -// @param inputs An array of InputData structs representing the multimodal -// input. -// @param num_inputs The number of InputData structs in the array. -// @return A pointer to the responses, or NULL on failure. The caller is -// responsible for deleting the responses using `litert_lm_responses_delete`. -LITERT_LM_C_API_EXPORT -LiteRtLmResponses* litert_lm_session_generate_content(LiteRtLmSession* session, - const InputData* inputs, - size_t num_inputs); -// Destroys a LiteRT LM Responses object. -// -// @param responses The responses to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_responses_delete(LiteRtLmResponses* responses); - -// Returns the number of response candidates. -// -// @param responses The responses object. -// @return The number of candidates. -LITERT_LM_C_API_EXPORT -int litert_lm_responses_get_num_candidates(const LiteRtLmResponses* responses); - -// Returns the response text at a given index. -// -// @param responses The responses object. -// @param index The index of the response. -// @return The response text. The returned string is owned by the `responses` -// object and is valid only for its lifetime. Returns NULL if index is out of -// bounds. -LITERT_LM_C_API_EXPORT -const char* litert_lm_responses_get_response_text_at( - const LiteRtLmResponses* responses, int index); - -// Retrieves the benchmark information from the session. The caller is -// responsible for destroying the benchmark info using -// `litert_lm_benchmark_info_delete`. -// -// @param session The session to get the benchmark info from. -// @return A pointer to the benchmark info, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmBenchmarkInfo* litert_lm_session_get_benchmark_info( - LiteRtLmSession* session); - -// Destroys a LiteRT LM Benchmark Info object. -// -// @param benchmark_info The benchmark info to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_benchmark_info_delete(LiteRtLmBenchmarkInfo* benchmark_info); - -// Returns the time to the first token in seconds. -// -// Note that the first time to token doesn't include the time for -// initialization. It is the sum of the prefill time for the first turn and -// the time spent for decoding the first token. -// -// @param benchmark_info The benchmark info object. -// @return The time to the first token in seconds. -LITERT_LM_C_API_EXPORT -double litert_lm_benchmark_info_get_time_to_first_token( - const LiteRtLmBenchmarkInfo* benchmark_info); - -// Returns the total initialization time in seconds. -// -// @param benchmark_info The benchmark info object. -// @return The total initialization time in seconds. -LITERT_LM_C_API_EXPORT -double litert_lm_benchmark_info_get_total_init_time_in_second( - const LiteRtLmBenchmarkInfo* benchmark_info); - -// Returns the number of prefill turns. -// -// @param benchmark_info The benchmark info object. -// @return The number of prefill turns. -LITERT_LM_C_API_EXPORT -int litert_lm_benchmark_info_get_num_prefill_turns( - const LiteRtLmBenchmarkInfo* benchmark_info); - -// Returns the number of decode turns. -// -// @param benchmark_info The benchmark info object. -// @return The number of decode turns. -LITERT_LM_C_API_EXPORT -int litert_lm_benchmark_info_get_num_decode_turns( - const LiteRtLmBenchmarkInfo* benchmark_info); - -// Returns the prefill token count at a given turn index. -// -// @param benchmark_info The benchmark info object. -// @param index The index of the prefill turn. -// @return The prefill token count. -LITERT_LM_C_API_EXPORT -int litert_lm_benchmark_info_get_prefill_token_count_at( - const LiteRtLmBenchmarkInfo* benchmark_info, int index); - -// Returns the decode token count at a given turn index. -// -// @param benchmark_info The benchmark info object. -// @param index The index of the decode turn. -// @return The decode token count. -LITERT_LM_C_API_EXPORT -int litert_lm_benchmark_info_get_decode_token_count_at( - const LiteRtLmBenchmarkInfo* benchmark_info, int index); - -// Returns the prefill tokens per second at a given turn index. -// -// @param benchmark_info The benchmark info object. -// @param index The index of the prefill turn. -// @return The prefill tokens per second. -LITERT_LM_C_API_EXPORT -double litert_lm_benchmark_info_get_prefill_tokens_per_sec_at( - const LiteRtLmBenchmarkInfo* benchmark_info, int index); - -// Returns the decode tokens per second at a given turn index. -// -// @param benchmark_info The benchmark info object. -// @param index The index of the decode turn. -// @return The decode tokens per second. -LITERT_LM_C_API_EXPORT -double litert_lm_benchmark_info_get_decode_tokens_per_sec_at( - const LiteRtLmBenchmarkInfo* benchmark_info, int index); - -// Callback for streaming responses. -// `callback_data` is a pointer to user-defined data passed to the stream -// function. `chunk` is the piece of text from the stream. It's only valid for -// the duration of the call. `is_final` is true if this is the last chunk in the -// stream. `error_msg` is a null-terminated string with an error message, or -// NULL on success. -typedef void (*LiteRtLmStreamCallback)(void* callback_data, const char* chunk, - bool is_final, const char* error_msg); - -// Generates content from the input prompt and streams the response via a -// callback. This is a non-blocking call that will invoke the callback from a -// background thread for each chunk. -// -// @param session The session to use for generation. -// @param inputs An array of InputData structs representing the multimodal -// input. -// @param num_inputs The number of InputData structs in the array. -// @param callback The callback function to receive response chunks. -// @param callback_data A pointer to user data that will be passed to the -// callback. -// @return 0 on success, non-zero on failure to start the stream. -LITERT_LM_C_API_EXPORT -int litert_lm_session_generate_content_stream(LiteRtLmSession* session, - const InputData* inputs, - size_t num_inputs, - LiteRtLmStreamCallback callback, - void* callback_data); - -// Creates a LiteRT LM Conversation. The caller is responsible for destroying -// the conversation using `litert_lm_conversation_delete`. -// -// @param engine The engine to create the conversation from. -// @param config The conversation config to use. If NULL, the default config -// will be used. -// @return A pointer to the created conversation, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmConversation* litert_lm_conversation_create( - LiteRtLmEngine* engine, LiteRtLmConversationConfig* config); - -// Destroys a LiteRT LM Conversation. -// -// @param conversation The conversation to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_conversation_delete(LiteRtLmConversation* conversation); - -// Sends a message to the conversation and returns the response. -// This is a blocking call. -// -// @param conversation The conversation to use. -// @param message_json A JSON string representing the message to send. -// @param extra_context A JSON string representing the extra context to use. -// @return A pointer to the JSON response, or NULL on failure. The caller is -// responsible for deleting the response using -// `litert_lm_json_response_delete`. -LITERT_LM_C_API_EXPORT -LiteRtLmJsonResponse* litert_lm_conversation_send_message( - LiteRtLmConversation* conversation, const char* message_json, - const char* extra_context); - -// Destroys a LiteRT LM Json Response object. -// -// @param response The response to destroy. -LITERT_LM_C_API_EXPORT -void litert_lm_json_response_delete(LiteRtLmJsonResponse* response); - -// Returns the JSON response string from a response object. -// -// @param response The response object. -// @return The response JSON string. The returned string is owned by the -// `response` object and is valid only for its lifetime. Returns NULL if -// response is NULL. -LITERT_LM_C_API_EXPORT -const char* litert_lm_json_response_get_string( - const LiteRtLmJsonResponse* response); - -// Sends a message to the conversation and streams the response via a -// callback. This is a non-blocking call that will invoke the callback from a -// background thread for each chunk. -// -// @param conversation The conversation to use. -// @param message_json A JSON string representing the message to send. -// @param extra_context A JSON string representing the extra context to use. -// @param callback The callback function to receive response chunks. -// @param callback_data A pointer to user data that will be passed to the -// callback. -// @return 0 on success, non-zero on failure to start the stream. -LITERT_LM_C_API_EXPORT -int litert_lm_conversation_send_message_stream( - LiteRtLmConversation* conversation, const char* message_json, - const char* extra_context, LiteRtLmStreamCallback callback, - void* callback_data); - -// Cancels the ongoing inference process, for asynchronous inference. -// -// @param conversation The conversation to cancel the inference for. -LITERT_LM_C_API_EXPORT -void litert_lm_conversation_cancel_process(LiteRtLmConversation* conversation); - -// Retrieves the benchmark information from the conversation. The caller is -// responsible for destroying the benchmark info using -// `litert_lm_benchmark_info_delete`. -// -// @param conversation The conversation to get the benchmark info from. -// @return A pointer to the benchmark info, or NULL on failure. -LITERT_LM_C_API_EXPORT -LiteRtLmBenchmarkInfo* litert_lm_conversation_get_benchmark_info( - LiteRtLmConversation* conversation); - -#ifdef __cplusplus -} // extern "C" -#endif - -#endif // THIRD_PARTY_ODML_LITERT_LM_C_ENGINE_H_ diff --git a/PhoneClawMac/Sources/CLiteRtLm/module.modulemap b/PhoneClawMac/Sources/CLiteRtLm/module.modulemap deleted file mode 100644 index 3cd282f..0000000 --- a/PhoneClawMac/Sources/CLiteRtLm/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module CLiteRtLm { - header "include/engine.h" - link "LiteRtLmEngine" - export * -} diff --git a/PhoneClawMac/Sources/CLiteRtLm/shim.c b/PhoneClawMac/Sources/CLiteRtLm/shim.c deleted file mode 100644 index fb624a6..0000000 --- a/PhoneClawMac/Sources/CLiteRtLm/shim.c +++ /dev/null @@ -1,2 +0,0 @@ -// Placeholder for CLiteRtLm C library -// The actual implementation is in libLiteRtLmEngine.dylib diff --git a/PhoneClawMac/Sources/PhoneClawMac/Agent/AgentEngine.swift b/PhoneClawMac/Sources/PhoneClawMac/Agent/AgentEngine.swift deleted file mode 100644 index 0c3cd2d..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/Agent/AgentEngine.swift +++ /dev/null @@ -1,648 +0,0 @@ -import Foundation - -// MARK: - 模型/推理配置 - -@Observable -class ModelConfig { - var maxTokens = 4000 - var topK = 64 - var topP = 0.95 - var temperature = 1.0 - var useGPU = true - var systemPrompt = """ - 你是 PhoneClaw,一个运行在本地设备上的私人 AI 助手。你完全离线运行,不联网,保护用户隐私。 - - 你拥有以下能力(Skill): - - ___SKILLS___ - - 当用户的请求需要使用某个能力时,先调用 load_skill 加载该能力的详细指令: - - {"name": "load_skill", "arguments": {"skill": "能力名"}} - - - 不需要能力时直接回复。用中文回答,简洁实用。 - """ -} - -// MARK: - 聊天消息 - -struct ChatMessage: Identifiable { - let id: UUID - var role: Role - var content: String - let timestamp = Date() - var skillName: String? = nil - - init(role: Role, content: String, skillName: String? = nil) { - self.id = UUID() - self.role = role - self.content = content - self.skillName = skillName - } - - /// 保持 id 不变地更新内容(避免 SwiftUI 重建视图) - mutating func update(content: String) { - // 只在内容真正变化时更新,减少不必要的 SwiftUI 通知 - guard self.content != content else { return } - self.content = content - } - - mutating func update(role: Role, content: String, skillName: String? = nil) { - self.role = role - self.content = content - self.skillName = skillName - } - - enum Role { - case user, assistant, system, skillResult - } -} - -// MARK: - Agent Engine - -@Observable -class AgentEngine { - - let llm = LocalLLMService() - var messages: [ChatMessage] = [] - var isProcessing = false - var config = ModelConfig() - - // 文件驱动的 Skill 系统 - let skillLoader = SkillLoader() - let toolRegistry = ToolRegistry.shared - - // Skill 条目(给 UI 管理用,可开关) - var skillEntries: [SkillEntry] = [] - - // 默认系统提示 - var defaultSystemPrompt: String { - ModelConfig().systemPrompt - } - - /// 已启用的 Skill 概要(只含 Skill 级别信息,不含 Tool 细节) - /// 用于 system prompt 和 UI chip - var enabledSkillInfos: [SkillInfo] { - skillEntries.filter(\.isEnabled).map { - SkillInfo(name: $0.id, description: $0.description, - displayName: $0.name, icon: $0.icon, samplePrompt: $0.samplePrompt) - } - } - - init() { - loadSkillEntries() - } - - /// 从 SKILL.md 文件加载所有 Skill - private func loadSkillEntries() { - let definitions = skillLoader.discoverSkills() - self.skillEntries = definitions.map { SkillEntry(from: $0, registry: toolRegistry) } - } - - /// 热重载所有 Skill(编辑/新增 SKILL.md 后调用) - func reloadSkills() { - // 保存当前启用状态 - let enabledState = Dictionary(uniqueKeysWithValues: skillEntries.map { ($0.id, $0.isEnabled) }) - loadSkillEntries() - // 恢复启用状态 - for i in skillEntries.indices { - if let wasEnabled = enabledState[skillEntries[i].id] { - skillEntries[i].isEnabled = wasEnabled - skillLoader.setEnabled(skillEntries[i].id, enabled: wasEnabled) - } - } - } - - // MARK: - Skill 查找(文件驱动) - - /// 根据 Skill ID 或 Tool 名找到 Skill ID - private func findSkillId(for name: String) -> String? { - // 先按 Skill ID 匹配 - if skillLoader.getDefinition(name) != nil { return name } - // 再按 Tool 名反查 - return skillLoader.findSkillId(forTool: name) - } - - /// 根据 Skill ID 或 Tool 名找到 displayName - private func findDisplayName(for name: String) -> String { - if let skillId = findSkillId(for: name), - let def = skillLoader.getDefinition(skillId) { - return def.metadata.name - } - return name - } - - /// 处理 load_skill 调用 → 返回 SKILL.md 的 body 作为 instructions - private func handleLoadSkill(skillName: String) -> String? { - // 检查是否启用 - guard let entry = skillEntries.first(where: { $0.id == skillName }), - entry.isEnabled else { - return nil - } - // 加载 SKILL.md body(渐进式披露) - return skillLoader.loadBody(skillId: skillName) - } - - /// 处理具体 Tool 调用 → 通过 ToolRegistry 执行 - private func handleToolExecution(toolName: String, args: [String: Any]) async throws -> String { - return try await toolRegistry.execute(name: toolName, args: args) - } - - // MARK: - 初始化 - - func setup() { - applySamplingConfig() - let backend = config.useGPU ? "gpu" : "cpu" - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.llm.loadModel(backend: backend) - } - } - - /// 将 config 中的采样参数同步到 LLM - func applySamplingConfig() { - llm.samplingTopK = Int32(config.topK) - llm.samplingTopP = Float(config.topP) - llm.samplingTemperature = Float(config.temperature) - llm.maxOutputTokens = config.maxTokens - } - - /// 重新加载模型(backend 变化时调用) - func reloadModel() { - let backend = config.useGPU ? "gpu" : "cpu" - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.llm.loadModel(backend: backend) - } - } - - // MARK: - 处理用户输入(LiteRT-LM C API 流式输出) - - func processInput(_ text: String) async { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, !isProcessing else { return } - guard llm.isLoaded else { - messages.append(ChatMessage(role: .system, content: "⏳ 模型还在加载中...")) - return - } - - messages.append(ChatMessage(role: .user, content: trimmed)) - isProcessing = true - - // ★ 每次生成前同步采样参数 - applySamplingConfig() - - let activeSkillInfos = enabledSkillInfos - let prompt = PromptBuilder.build( - userMessage: trimmed, - tools: activeSkillInfos, - history: messages, - systemPrompt: config.systemPrompt - ) - - // 占位消息(▍ 不可见,只显示 spinner) - messages.append(ChatMessage(role: .assistant, content: "▍")) - let msgIndex = messages.count - 1 - - var detectedToolCall = false - var buffer = "" // 缓冲区:积累 token,确认不是 tool_call 后才显示 - var bufferFlushed = false // 一旦 flush 过,后续 token 直接显示 - - llm.generateStream(prompt: prompt) { [weak self] token in - guard let self = self else { return } - - if detectedToolCall { - buffer += token - return // 静默积累,不更新 UI — 由 executeToolChain 统一管理卡片 - } - - buffer += token - - if buffer.contains("") { - detectedToolCall = true - return // 不转换消息角色,保持 assistant "▍" → 显示思考点 - } - - if !bufferFlushed { - let trimmed = buffer.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return } - if "".hasPrefix(trimmed) { return } - bufferFlushed = true - self.messages[msgIndex].update(content: self.cleanOutputStreaming(buffer)) - return - } - - // ★ 原地更新 content,保持 id 不变 → 无重排 - // 用 buffer(完整原始文本)做清理,避免累积误差 - let cleaned = self.cleanOutputStreaming(buffer) - if !cleaned.isEmpty { - self.messages[msgIndex].update(content: cleaned) - } - } onComplete: { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let fullText): - log("[Agent] 1st raw: \(fullText.prefix(300))") - - if self.parseToolCall(fullText) != nil { - // ★ 清除原始占位消息(executeToolChain 会创建正确的 skill 卡片) - self.messages[msgIndex].update(content: "") - // ★ 进入工具链循环(支持多轮) - Task { - await self.executeToolChain( - prompt: prompt, - fullText: fullText, - userQuestion: trimmed - ) - } - return - } else { - let cleaned = self.cleanOutput(fullText) - self.messages[msgIndex].update( - content: cleaned.isEmpty ? "(无回复)" : cleaned - ) - } - case .failure(let error): - self.messages[msgIndex].update(role: .system, content: "❌ \(error.localizedDescription)") - } - self.isProcessing = false - } - } - - // MARK: - Skill 结果后的后续推理(支持多轮工具链) - - /// 执行一次 LLM 推理并返回原始输出 - private func streamLLM(prompt: String, msgIndex: Int) async -> String? { - var buffer = "" - return await withCheckedContinuation { (continuation: CheckedContinuation) in - var toolCallDetected = false - var bufferFlushed = false // 是否已确认不是 tool_call 前缀 - llm.generateStream(prompt: prompt) { [weak self] token in - guard let self = self else { return } - buffer += token - - // 检测到 tool_call 后静默积累 - if toolCallDetected { return } - if buffer.contains("") { - toolCallDetected = true - // 清除之前可能已显示的过程文字 - if bufferFlushed && self.messages[msgIndex].role == .assistant { - self.messages[msgIndex].update(content: "") - } - return - } - - // 等待确认不是 tool_call 前缀再显示 - if !bufferFlushed { - let trimmed = buffer.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return } - if "".hasPrefix(trimmed) { return } - bufferFlushed = true - } - - // 普通文字流式显示 - let cleaned = self.cleanOutputStreaming(buffer) - if !cleaned.isEmpty && self.messages[msgIndex].role == .assistant { - self.messages[msgIndex].update(content: cleaned) - } - } onComplete: { [weak self] result in - guard let self = self else { - continuation.resume(returning: nil) - return - } - switch result { - case .success(let text): - log("[Agent] LLM raw: \(text.prefix(300))") - continuation.resume(returning: text) - case .failure(let error): - self.messages[msgIndex].update(role: .system, content: "❌ \(error.localizedDescription)") - continuation.resume(returning: nil) - } - } - } - } - - /// 执行 tool_call → skill/tool → follow-up 循环(最多 maxRounds 轮) - /// - /// 支持两种调用: - /// - load_skill: 逐级披露,返回 Skill 的完整 instructions + Tool 定义 - /// - 具体 tool: 执行原生 API - private func executeToolChain( - prompt: String, - fullText: String, - userQuestion: String, - round: Int = 1, - maxRounds: Int = 10 - ) async { - guard round <= maxRounds else { - log("[Agent] 达到最大工具链轮数 \(maxRounds)") - isProcessing = false - return - } - - guard let call = parseToolCall(fullText) else { - let cleaned = cleanOutput(fullText) - if let lastAssistant = messages.lastIndex(where: { $0.role == .assistant }) { - messages[lastAssistant].update(content: cleaned.isEmpty ? "(无回复)" : cleaned) - } - isProcessing = false - return - } - - log("[Agent] Round \(round): tool_call name=\(call.name)") - - // ── load_skill:逐级披露,返回 Skill 指令 ── - if call.name == "load_skill" { - // ★ 检查是否有多个 load_skill(模型可能一次请求多个能力) - let allCalls = parseAllToolCalls(fullText) - let loadSkillCalls = allCalls.filter { $0.name == "load_skill" } - - var allInstructions = "" - for lsCall in loadSkillCalls { - let skillName = (lsCall.arguments["skill"] as? String) - ?? (lsCall.arguments["name"] as? String) - ?? "" - log("[Agent] load_skill: \(skillName)") - - let displayName = findDisplayName(for: skillName) - messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName)) - let cardIdx = messages.count - 1 - - guard let instructions = handleLoadSkill(skillName: skillName) else { - messages[cardIdx].update(role: .system, content: "done", skillName: displayName) - continue - } - - try? await Task.sleep(for: .milliseconds(300)) - messages[cardIdx].update(role: .system, content: "loaded", skillName: displayName) - messages.append(ChatMessage(role: .skillResult, content: instructions, skillName: skillName)) - allInstructions += instructions + "\n\n" - } - - guard !allInstructions.isEmpty else { - isProcessing = false - return - } - - // 构建 follow-up:把所有 instructions 注入 LLM 上下文 - let followUpPrompt = PromptBuilder.buildFollowUp( - originalPrompt: prompt, - modelResponse: fullText, - skillName: "load_skill", - skillResult: allInstructions, - userQuestion: userQuestion, - isLoadSkill: true - ) - - messages.append(ChatMessage(role: .assistant, content: "▍")) - let followUpIndex = messages.count - 1 - - guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex) else { - isProcessing = false - return - } - - if parseToolCall(nextText) != nil { - log("[Agent] load_skill 后检测到 tool 调用 (round \(round + 1))") - messages[followUpIndex].update(content: "") - await executeToolChain( - prompt: followUpPrompt, fullText: nextText, - userQuestion: userQuestion, round: round + 1, maxRounds: maxRounds - ) - } else { - let cleaned = cleanOutput(nextText) - messages[followUpIndex].update(content: cleaned.isEmpty ? "(无回复)" : cleaned) - isProcessing = false - } - return - } - - // ── 具体 Tool 调用:执行原生 API ── - - // 定位所属 Skill(用于 UI 展示) - let ownerSkillId = findSkillId(for: call.name) - let displayName = findDisplayName(for: call.name) - - // 找已有的 Skill 卡片(load_skill 阶段创建的,状态为 "loaded") - // 复用它来继续显示 Step 3: executing - let cardIndex: Int - if let idx = messages.lastIndex(where: { - $0.role == .system && ($0.skillName == displayName || $0.skillName == call.name) - && ($0.content == "identified" || $0.content == "loaded") - }) { - cardIndex = idx - } else { - // 没有已有卡片(直接 Tool 调用,跳过 load_skill),创建新卡片 - messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName)) - cardIndex = messages.count - 1 - } - - // ★ 未知工具:关闭卡片 + 报错 - guard ownerSkillId != nil else { - log("[Agent] 未知工具: \(call.name)") - messages[cardIndex].update(role: .system, content: "done", skillName: displayName) - messages.append(ChatMessage(role: .assistant, content: "⚠️ 未知工具: \(call.name)")) - isProcessing = false - return - } - - // ★ Skill 未启用:关闭卡片 + 报错 - let enabledIds = Set(skillEntries.filter(\.isEnabled).map(\.id)) - guard enabledIds.contains(ownerSkillId!) else { - messages[cardIndex].update(role: .system, content: "done", skillName: displayName) - messages.append(ChatMessage(role: .assistant, content: "⚠️ Skill \(displayName) 未启用")) - isProcessing = false - return - } - - // Step 3: executing(带具体 Tool 名) - messages[cardIndex].update(role: .system, content: "executing:\(call.name)", skillName: displayName) - - do { - let toolResult = try await handleToolExecution(toolName: call.name, args: call.arguments) - messages[cardIndex].update(role: .system, content: "done", skillName: displayName) - messages.append(ChatMessage(role: .skillResult, content: toolResult, skillName: call.name)) - log("[Agent] Tool \(call.name) round \(round) done") - - let followUpPrompt = PromptBuilder.buildFollowUp( - originalPrompt: prompt, - modelResponse: fullText, - skillName: call.name, - skillResult: toolResult, - userQuestion: userQuestion - ) - - messages.append(ChatMessage(role: .assistant, content: "▍")) - let followUpIndex = messages.count - 1 - - guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex) else { - isProcessing = false - return - } - - if !parseAllToolCalls(nextText).isEmpty { - log("[Agent] 检测到第 \(round + 1) 轮工具调用") - messages[followUpIndex].update(content: "") - await executeToolChain( - prompt: followUpPrompt, fullText: nextText, - userQuestion: userQuestion, round: round + 1, maxRounds: maxRounds - ) - } else { - let cleaned = cleanOutput(nextText) - messages[followUpIndex].update(content: cleaned.isEmpty ? "(无回复)" : cleaned) - isProcessing = false - } - } catch { - messages[cardIndex].update(role: .system, content: "done", skillName: displayName) - messages.append(ChatMessage(role: .system, content: "❌ Tool 执行失败: \(error)")) - isProcessing = false - } - } - - // MARK: - 工具 - - func clearMessages() { - messages.removeAll() - } - - func setAllSkills(enabled: Bool) { - for i in skillEntries.indices { - skillEntries[i].isEnabled = enabled - } - } - - // MARK: - 解析 - - private func parseToolCall(_ text: String) -> (name: String, arguments: [String: Any])? { - return parseAllToolCalls(text).first - } - - /// 解析所有 tool_call(支持模型在一次回复中输出多个) - private func parseAllToolCalls(_ text: String) -> [(name: String, arguments: [String: Any])] { - var results: [(name: String, arguments: [String: Any])] = [] - let patterns = [ - "\\s*(\\{.*?\\})\\s*", - "```json\\s*(\\{.*?\\})\\s*```", - "\\s*(\\{.*?\\})\\s*" - ] - for pattern in patterns { - guard let regex = try? NSRegularExpression(pattern: pattern, options: .dotMatchesLineSeparators) else { continue } - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - for match in matches { - if let jsonRange = Range(match.range(at: 1), in: text) { - let json = String(text[jsonRange]) - if let data = json.data(using: .utf8), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let name = dict["name"] as? String { - results.append((name, dict["arguments"] as? [String: Any] ?? [:])) - } - } - } - if !results.isEmpty { break } - } - return results - } - - /// 从部分流式内容中提取 skill 名(不需要完整 JSON) - private func extractSkillName(from text: String) -> String? { - // 匹配 "name": "xxx" 或 "name":"xxx" - guard let regex = try? NSRegularExpression(pattern: "\"name\"\\s*:\\s*\"([^\"]+)\""), - let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), - let nameRange = Range(match.range(at: 1), in: text) else { - return nil - } - return String(text[nameRange]) - } - - /// 流式输出时的轻量清理:只移除已完成的 token,不做尾部裁剪 - /// 保证文本长度单调递增,避免视觉跳动 - private func cleanOutputStreaming(_ text: String) -> String { - var result = text - - // ★ 截断:遇到 只保留之前的内容 - if let tcRange = result.range(of: "") { - result = String(result[result.startIndex..", "", ""] - for pat in endPatterns { - if let range = result.range(of: pat) { - result = String(result[result.startIndex..... 块 - if let regex = try? NSRegularExpression(pattern: ".*?", options: .dotMatchesLineSeparators) { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") - } - - // 只移除已完整闭合的 token(不动尾部未闭合的) - result = result.replacingOccurrences( - of: "<\\|?[/a-z_]+\\|?>", - with: "", - options: .regularExpression - ) - - // 清理开头的 "model\n" - if result.hasPrefix("model\n") { - result = String(result.dropFirst(6)) - } else if result == "model" { - return "" - } - - // ★ 流式时不做 trimmingCharacters,避免尾部空白被反复移除/恢复 - // 只移除开头空白 - return String(result.drop(while: { $0.isWhitespace || $0.isNewline })) - } - - /// 完成时的完整清理:包括尾部裁剪和空白处理 - private func cleanOutput(_ text: String) -> String { - var result = text - - // ★ 移除完整的 ... 块 - if let regex = try? NSRegularExpression(pattern: ".*?", options: .dotMatchesLineSeparators) { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") - } - - // ★ 截断:遇到未闭合的 ") { - result = String(result[result.startIndex..", "", ""] - for pat in endPatterns { - if let range = result.range(of: pat) { - result = String(result[result.startIndex..", - with: "", - options: .regularExpression - ) - - // 清理未闭合的尾部 < 残留(仅完成时做) - if let lastOpen = result.lastIndex(of: "<") { - let tail = String(result[lastOpen...]) - let tailBody = tail.dropFirst() - if !tailBody.isEmpty && tailBody.allSatisfy({ $0.isLetter || $0 == "_" || $0 == "/" || $0 == "|" }) { - result = String(result[result.startIndex.. Void, - onComplete: @escaping (Result) -> Void - ) { - guard let engine = engine else { - onComplete(.failure(LLMError.notLoaded)) - return - } - - DispatchQueue.main.async { self.isGenerating = true } - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - - // ★ 清理上一个 session - self.cleanupSession() - - // 创建 session config(应用采样参数) - let sessionConfig = litert_lm_session_config_create() - if let cfg = sessionConfig { - litert_lm_session_config_set_max_output_tokens(cfg, Int32(self.maxOutputTokens)) - var params = LiteRtLmSamplerParams() - params.top_k = self.samplingTopK - params.top_p = self.samplingTopP - params.temperature = self.samplingTemperature - params.seed = 0 - litert_lm_session_config_set_sampler_params(cfg, ¶ms) - log("[LLM] Session config: topK=\(params.top_k) topP=\(params.top_p) temp=\(params.temperature) maxTokens=\(self.maxOutputTokens)") - } - - // 创建新 session(传入 config) - let session = litert_lm_engine_create_session(engine, sessionConfig) - if let cfg = sessionConfig { litert_lm_session_config_delete(cfg) } - guard let session = session else { - DispatchQueue.main.async { - self.isGenerating = false - onComplete(.failure(LLMError.sessionFailed)) - } - log("[LLM] Session 创建失败") - return - } - - self.sessionLock.lock() - self.activeSession = session - self.sessionLock.unlock() - log("[LLM] Session 创建成功") - - // 构建 InputData(原始文本) - let cPrompt = strdup(prompt)! - var input = InputData() - input.type = kInputText - input.data = UnsafeRawPointer(cPrompt) - input.size = strlen(cPrompt) - - // 创建流式回调上下文 - let context = StreamContext(onToken: onToken, onComplete: { [weak self] result in - DispatchQueue.main.async { - self?.isGenerating = false - onComplete(result) - } - // ★ 不在回调中删除 session,由下次 generateStream 清理 - }) - let contextPtr = Unmanaged.passRetained(context).toOpaque() - - let result = litert_lm_session_generate_content_stream( - session, - &input, - 1, - { callbackData, chunk, isFinal, errorMsg in - guard let ctx = callbackData else { return } - let context = Unmanaged.fromOpaque(ctx).takeUnretainedValue() - - if let err = errorMsg { - let errStr = String(cString: err) - context.finish(with: .failure(LLMError.inferenceError(errStr))) - Unmanaged.fromOpaque(ctx).release() - return - } - - if let chunk = chunk { - let token = String(cString: chunk) - // Session API 返回纯文本 token(不是 JSON) - context.fullResponse += token - DispatchQueue.main.async { - context.onToken(token) - } - } - - if isFinal { - context.finish(with: .success(context.fullResponse)) - Unmanaged.fromOpaque(ctx).release() - } - }, - contextPtr - ) - - free(cPrompt) - - if result != 0 { - Unmanaged.fromOpaque(contextPtr).release() - DispatchQueue.main.async { - self.isGenerating = false - onComplete(.failure(LLMError.predictFailed)) - } - log("[LLM] 流式生成启动失败, code=\(result)") - } - } - } - - // MARK: - Helpers - - private func findModelPath() -> String? { - let candidates = [ - Bundle.main.path(forResource: Self.modelFileName, ofType: Self.modelFileExtension), - "/Users/zxw/AITOOL/phoneclaw/Models/\(Self.modelFileName).\(Self.modelFileExtension)", - ] - for path in candidates { - if let p = path, FileManager.default.fileExists(atPath: p) { - return p - } - } - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let docFile = docs.appendingPathComponent("\(Self.modelFileName).\(Self.modelFileExtension)") - if FileManager.default.fileExists(atPath: docFile.path) { - return docFile.path - } - return nil - } - - static func jsonEscape(_ str: String) -> String { - let data = try! JSONSerialization.data(withJSONObject: [str]) - let arrayStr = String(data: data, encoding: .utf8)! - let start = arrayStr.index(after: arrayStr.startIndex) - let end = arrayStr.index(before: arrayStr.endIndex) - return String(arrayStr[start.. Void - let onComplete: (Result) -> Void - - init(onToken: @escaping (String) -> Void, onComplete: @escaping (Result) -> Void) { - self.onToken = onToken - self.onComplete = onComplete - } - - func finish(with result: Result) { - guard !completed else { return } - completed = true - onComplete(result) - } -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/LLM/PromptBuilder.swift b/PhoneClawMac/Sources/PhoneClawMac/LLM/PromptBuilder.swift deleted file mode 100644 index bca1fb5..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/LLM/PromptBuilder.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation - -// MARK: - Prompt 构造器(Gemma 4 对话模板 + Function Calling) -// -// Gemma 4 E4B 使用新 token 格式: -// <|turn>system\n ... -// <|turn>user\n ... -// <|turn>model\n ... - -struct PromptBuilder { - - static let defaultSystemPrompt = "你是 PhoneClaw,一个运行在本地的私人 AI 助手。你完全运行在设备上,不联网。" - - /// 构造完整 Prompt(包含工具定义 + 对话历史) - static func build( - userMessage: String, - tools: [SkillInfo], - history: [ChatMessage] = [], - systemPrompt: String? = nil - ) -> String { - var prompt = "<|turn>system\n" - - // ★ 使用自定义 system prompt(如果有),否则用默认 - let basePrompt = systemPrompt ?? defaultSystemPrompt - - // 构建 Skill 概要列表(只列名称 + 一句话描述,不暴露 Tool) - var skillListText = "" - for skill in tools { - skillListText += "- **\(skill.name)**: \(skill.description)\n" - } - - // 处理 ___SKILLS___ 占位符 - if basePrompt.contains("___SKILLS___") { - prompt += basePrompt.replacingOccurrences(of: "___SKILLS___", with: skillListText) - } else { - prompt += basePrompt - if !tools.isEmpty { - prompt += "\n\n你拥有以下能力(Skill):\n\n" + skillListText - prompt += "\n当用户的请求需要使用某个能力时,先调用 load_skill:\n\n{\"name\": \"load_skill\", \"arguments\": {\"skill\": \"能力名\"}}\n\n\n不需要能力时直接回复。用中文回答,简洁实用。" - } - } - - prompt += "\n\n" - - // 对话历史(最近几轮,排除最后一条当前用户消息,避免重复) - let recentHistory = history.suffix(12) - for msg in recentHistory { - // ★ 跳过最后一条 user 消息(等下面单独加) - if msg.role == .user && msg.id == recentHistory.last?.id { continue } - switch msg.role { - case .user: - prompt += "<|turn>user\n\(msg.content)\n" - case .assistant: - prompt += "<|turn>model\n\(msg.content)\n" - case .system: - if let skillName = msg.skillName { - prompt += "<|turn>model\n\n{\"name\": \"\(skillName)\", \"arguments\": {}}\n\n" - } - case .skillResult: - let skillLabel = msg.skillName ?? "tool" - prompt += "<|turn>user\n工具 \(skillLabel) 的执行结果:\(msg.content)\n" - } - } - - // 当前用户消息 - prompt += "<|turn>user\n\(userMessage)\n" - prompt += "<|turn>model\n" - - return prompt - } - - /// 构造工具/Skill 结果后的 follow-up prompt - /// - /// - isLoadSkill=true: load_skill 返回了 instructions,LLM 应按指令继续执行 - /// - isLoadSkill=false: Tool 执行完毕,LLM 应根据结果回答用户 - static func buildFollowUp( - originalPrompt: String, - modelResponse: String, - skillName: String, - skillResult: String, - userQuestion: String, - isLoadSkill: Bool = false - ) -> String { - var cleanedResponse = modelResponse - for pat in ["", "", ""] { - cleanedResponse = cleanedResponse.replacingOccurrences(of: pat, with: "") - } - - if isLoadSkill { - // load_skill 返回的是 Skill instructions → 让 LLM 按指令继续 - return originalPrompt + cleanedResponse + """ - - <|turn>user - Skill 指令已加载: - \(skillResult) - - 请根据以上指令,调用对应的工具来完成用户的请求:"\(userQuestion)" - - <|turn>model - - """ - } else { - // Tool 执行结果 → 让 LLM 生成自然语言回复 - return originalPrompt + cleanedResponse + """ - - <|turn>user - 工具 \(skillName) 执行结果: - \(skillResult) - - 请根据以上结果简洁回答我的问题:"\(userQuestion)" - - <|turn>model - - """ - } - } -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/Skills/SkillLoader.swift b/PhoneClawMac/Sources/PhoneClawMac/Skills/SkillLoader.swift deleted file mode 100644 index 4e72026..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/Skills/SkillLoader.swift +++ /dev/null @@ -1,363 +0,0 @@ -import Foundation -import Yams - -// MARK: - SKILL.md 解析器 + 加载器 -// -// 参考 Vera 项目的 skill_loader.py,实现 Swift 版。 -// 渐进式加载: -// 1. 启动时:只加载 YAML frontmatter(元数据) -// 2. load_skill 时:加载完整 body(指令体) - -// MARK: - 数据模型 - -struct SkillExample { - let query: String - let scenario: String -} - -struct SkillMetadata { - let id: String // 目录名 "clipboard" - let name: String // "剪贴板" - let description: String - let version: String - let icon: String - let disabled: Bool - let triggers: [String] - let allowedTools: [String] - let examples: [SkillExample] -} - -struct SkillDefinition: Identifiable { - let id: String - let filePath: URL - let metadata: SkillMetadata - var body: String? // Markdown body(懒加载) - var isEnabled: Bool - - /// 完整的 SKILL.md 原始内容 - var rawContent: String? { - try? String(contentsOf: filePath, encoding: .utf8) - } -} - -// MARK: - Skill Loader - -class SkillLoader { - - let skillsDirectory: URL - private var cache: [String: SkillDefinition] = [:] - - init() { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - self.skillsDirectory = appSupport.appendingPathComponent("PhoneClaw/skills", isDirectory: true) - ensureDefaultSkills() - } - - // MARK: - 公开接口 - - /// 发现并加载所有 Skill 的元数据 - func discoverSkills() -> [SkillDefinition] { - cache.removeAll() - guard let items = try? FileManager.default.contentsOfDirectory( - at: skillsDirectory, - includingPropertiesForKeys: [.isDirectoryKey], - options: .skipsHiddenFiles - ) else { return [] } - - var results: [SkillDefinition] = [] - for item in items.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { - var isDir: ObjCBool = false - FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) - guard isDir.boolValue else { continue } - - let skillFile = item.appendingPathComponent("SKILL.md") - guard FileManager.default.fileExists(atPath: skillFile.path) else { continue } - - let skillId = item.lastPathComponent - if let def = loadDefinition(skillId: skillId, file: skillFile) { - cache[skillId] = def - results.append(def) - } - } - return results - } - - /// 完整加载 Skill(包括 body)— load_skill 时调用 - func loadBody(skillId: String) -> String? { - if let cached = cache[skillId], cached.body != nil { - return cached.body - } - let skillFile = skillsDirectory - .appendingPathComponent(skillId, isDirectory: true) - .appendingPathComponent("SKILL.md") - - guard let content = try? String(contentsOf: skillFile, encoding: .utf8) else { return nil } - let body = parseBody(content) - cache[skillId]?.body = body - return body - } - - /// 保存 SKILL.md(编辑后写回) - func saveSkill(skillId: String, content: String) throws { - let skillFile = skillsDirectory - .appendingPathComponent(skillId, isDirectory: true) - .appendingPathComponent("SKILL.md") - try content.write(to: skillFile, atomically: true, encoding: .utf8) - // 清缓存,下次重新解析 - cache.removeValue(forKey: skillId) - } - - /// 重新加载所有(热更新入口) - func reloadAll() -> [SkillDefinition] { - return discoverSkills() - } - - /// 根据工具名反查 Skill ID - func findSkillId(forTool toolName: String) -> String? { - for (id, def) in cache { - if def.metadata.allowedTools.contains(toolName) { - return id - } - } - return nil - } - - /// 获取缓存的 SkillDefinition - func getDefinition(_ skillId: String) -> SkillDefinition? { - cache[skillId] - } - - /// 更新启用状态 - func setEnabled(_ skillId: String, enabled: Bool) { - cache[skillId]?.isEnabled = enabled - } - - // MARK: - 解析 - - private func loadDefinition(skillId: String, file: URL) -> SkillDefinition? { - guard let content = try? String(contentsOf: file, encoding: .utf8) else { return nil } - guard let frontmatter = parseFrontmatter(content) else { return nil } - - let metadata = SkillMetadata( - id: skillId, - name: frontmatter["name"] as? String ?? skillId, - description: frontmatter["description"] as? String ?? "", - version: frontmatter["version"] as? String ?? "1.0.0", - icon: frontmatter["icon"] as? String ?? "wrench", - disabled: frontmatter["disabled"] as? Bool ?? false, - triggers: frontmatter["triggers"] as? [String] ?? [], - allowedTools: frontmatter["allowed-tools"] as? [String] ?? [], - examples: parseExamples(frontmatter["examples"]) - ) - - return SkillDefinition( - id: skillId, - filePath: file, - metadata: metadata, - body: nil, // 懒加载 - isEnabled: !metadata.disabled - ) - } - - private func parseFrontmatter(_ content: String) -> [String: Any]? { - // 匹配 --- ... --- 包裹的 YAML - guard let regex = try? NSRegularExpression( - pattern: "^---\\s*\\n(.*?)\\n---\\s*\\n", - options: .dotMatchesLineSeparators - ) else { return nil } - - let range = NSRange(content.startIndex..., in: content) - guard let match = regex.firstMatch(in: content, range: range), - let yamlRange = Range(match.range(at: 1), in: content) else { return nil } - - let yamlString = String(content[yamlRange]) - // 用 Yams 解析 - guard let parsed = try? Yams.load(yaml: yamlString) as? [String: Any] else { return nil } - return parsed - } - - private func parseBody(_ content: String) -> String { - guard let regex = try? NSRegularExpression( - pattern: "^---\\s*\\n.*?\\n---\\s*\\n(.*)$", - options: .dotMatchesLineSeparators - ) else { return content } - - let range = NSRange(content.startIndex..., in: content) - if let match = regex.firstMatch(in: content, range: range), - let bodyRange = Range(match.range(at: 1), in: content) { - return String(content[bodyRange]).trimmingCharacters(in: .whitespacesAndNewlines) - } - return content.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func parseExamples(_ raw: Any?) -> [SkillExample] { - guard let list = raw as? [[String: Any]] else { return [] } - return list.compactMap { dict in - guard let query = dict["query"] as? String, - let scenario = dict["scenario"] as? String else { return nil } - return SkillExample(query: query, scenario: scenario) - } - } - - // MARK: - 首次启动:写入默认 Skill - - private func ensureDefaultSkills() { - let fm = FileManager.default - if !fm.fileExists(atPath: skillsDirectory.path) { - try? fm.createDirectory(at: skillsDirectory, withIntermediateDirectories: true) - } - - for (dirName, content) in Self.defaultSkills { - let dir = skillsDirectory.appendingPathComponent(dirName, isDirectory: true) - let file = dir.appendingPathComponent("SKILL.md") - if !fm.fileExists(atPath: file.path) { - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - try? content.write(to: file, atomically: true, encoding: .utf8) - } - } - } - - // MARK: - 内置默认 SKILL.md - - static let defaultSkills: [(String, String)] = [ - ("clipboard", """ - --- - name: Clipboard - description: '读写系统剪贴板内容。当用户需要读取、复制或操作剪贴板时使用。' - version: "1.0.0" - icon: doc.on.clipboard - disabled: false - - triggers: - - 剪贴板 - - 粘贴 - - 复制 - - clipboard - - allowed-tools: - - clipboard-read - - clipboard-write - - examples: - - query: "读取我的剪贴板内容" - scenario: "读取剪贴板" - - query: "把这段文字复制到剪贴板" - scenario: "写入剪贴板" - --- - - # 剪贴板操作 - - 你负责帮助用户读写系统剪贴板。 - - ## 可用工具 - - - **clipboard-read**: 读取剪贴板当前内容(无参数) - - **clipboard-write**: 将文本写入剪贴板(参数: text — 要复制的文本) - - ## 执行流程 - - 1. 用户要求读取 → 调用 `clipboard-read` - 2. 用户要求复制/写入 → 调用 `clipboard-write`,传入 text 参数 - 3. 根据工具返回结果,简洁回答用户 - - ## 调用格式 - - - {"name": "工具名", "arguments": {}} - - """), - - ("device", """ - --- - name: Device - description: '获取设备硬件和系统信息。当用户询问电脑配置、系统版本时使用。' - version: "1.0.0" - icon: desktopcomputer - disabled: false - - triggers: - - 设备 - - 电脑 - - 系统信息 - - 配置 - - 硬件 - - allowed-tools: - - device-info - - examples: - - query: "我的电脑是什么配置" - scenario: "查看设备信息" - - query: "系统版本是多少" - scenario: "查看系统版本" - --- - - # 设备信息查询 - - 你负责帮助用户查看设备的硬件和系统信息。 - - ## 可用工具 - - - **device-info**: 获取设备型号、系统版本、内存、处理器信息(无参数) - - ## 执行流程 - - 1. 调用 `device-info` 获取设备信息 - 2. 将返回的 JSON 转换为用户友好的描述 - - ## 调用格式 - - - {"name": "device-info", "arguments": {}} - - """), - - ("text", """ - --- - name: Text - description: '文本处理工具:哈希计算、翻转等。当用户需要对文本进行处理或转换时使用。' - version: "1.0.0" - icon: textformat - disabled: false - - triggers: - - 哈希 - - hash - - 翻转 - - 反转 - - 文本处理 - - allowed-tools: - - calculate-hash - - text-reverse - - examples: - - query: "计算 Hello World 的哈希值" - scenario: "哈希计算" - - query: "把这段文字翻转过来" - scenario: "文本翻转" - --- - - # 文本处理 - - 你负责帮助用户进行文本处理操作。 - - ## 可用工具 - - - **calculate-hash**: 计算文本的哈希值(参数: text — 要计算哈希的文本) - - **text-reverse**: 翻转文本(参数: text — 要翻转的文本) - - ## 执行流程 - - 1. 判断用户需要哪种文本操作 - 2. 调用对应工具,传入 text 参数 - 3. 返回处理结果 - - ## 调用格式 - - - {"name": "工具名", "arguments": {"text": "要处理的文本"}} - - """), - ] -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/Skills/Skills.swift b/PhoneClawMac/Sources/PhoneClawMac/Skills/Skills.swift deleted file mode 100644 index fe7f60e..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/Skills/Skills.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -// MARK: - Skill 数据模型 -// -// 文件驱动架构: -// - SKILL.md 定义 Skill 元数据 + 指令体(热更新) -// - ToolRegistry.swift 注册原生工具实现(编译时) -// - SkillLoader.swift 解析和加载 SKILL.md -// -// 以下仅为给 UI 和 PromptBuilder 使用的精简数据结构。 - -// MARK: - Skill 条目(给 UI 管理用) - -struct ToolInfo: Equatable { - let name: String - let description: String - let parameters: String -} - -struct SkillEntry: Identifiable { - let id: String // skill directory name, e.g. "clipboard" - var name: String // display name, e.g. "Clipboard" - var description: String - var icon: String - var samplePrompt: String - var tools: [ToolInfo] = [] - var isEnabled: Bool = true - var filePath: URL? // SKILL.md 路径(用于编辑) - - /// 从 SkillDefinition 转换 - init(from def: SkillDefinition, registry: ToolRegistry) { - self.id = def.id - self.name = def.metadata.name - self.description = def.metadata.description - self.icon = def.metadata.icon - self.samplePrompt = def.metadata.examples.first?.query ?? "" - self.isEnabled = def.isEnabled - self.filePath = def.filePath - self.tools = def.metadata.allowedTools.compactMap { toolName in - guard let tool = registry.find(name: toolName) else { return nil } - return ToolInfo(name: tool.name, description: tool.description, parameters: tool.parameters) - } - } -} - -// MARK: - SkillInfo(给 PromptBuilder 用的精简描述) - -struct SkillInfo { - let name: String // skill id, e.g. "clipboard" - let description: String - var displayName: String = "" - var icon: String = "wrench" - var samplePrompt: String = "" -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/Skills/ToolRegistry.swift b/PhoneClawMac/Sources/PhoneClawMac/Skills/ToolRegistry.swift deleted file mode 100644 index f3036d3..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/Skills/ToolRegistry.swift +++ /dev/null @@ -1,144 +0,0 @@ -import AppKit -import Foundation - -// MARK: - 原生工具注册表 -// -// 所有原生 API 封装集中注册在这里。 -// SKILL.md 通过 allowed-tools 字段引用工具名。 - -struct RegisteredTool { - let name: String - let description: String - let parameters: String - let execute: ([String: Any]) async throws -> String -} - -class ToolRegistry { - static let shared = ToolRegistry() - - private var tools: [String: RegisteredTool] = [:] - - init() { - registerBuiltInTools() - } - - // MARK: - 公开接口 - - func register(_ tool: RegisteredTool) { - tools[tool.name] = tool - } - - func find(name: String) -> RegisteredTool? { - tools[name] - } - - func execute(name: String, args: [String: Any]) async throws -> String { - guard let tool = tools[name] else { - return "{\"success\": false, \"error\": \"未知工具: \(name)\"}" - } - return try await tool.execute(args) - } - - /// 根据名称列表获取工具(用于 SKILL.md 的 allowed-tools) - func toolsFor(names: [String]) -> [RegisteredTool] { - names.compactMap { tools[$0] } - } - - /// 根据工具名反查:它属于哪些 allowed-tools 列表 - /// 返回 true 如果该工具已注册 - func hasToolNamed(_ name: String) -> Bool { - tools[name] != nil - } - - /// 所有已注册的工具名 - var allToolNames: [String] { - Array(tools.keys).sorted() - } - - // MARK: - 内置工具注册 - - private func registerBuiltInTools() { - // ── Clipboard ── - register(RegisteredTool( - name: "clipboard-read", - description: "读取剪贴板当前内容", - parameters: "无" - ) { _ in - let content = NSPasteboard.general.string(forType: .string) - if let raw = content, !raw.isEmpty { - let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return "{\"success\": false, \"error\": \"剪贴板为空\"}" } - return "{\"success\": true, \"type\": \"text\", \"content\": \"\(jsonEscape(String(text.prefix(500))))\", \"length\": \(text.count)}" - } - return "{\"success\": false, \"error\": \"剪贴板为空\"}" - }) - - register(RegisteredTool( - name: "clipboard-write", - description: "将文本写入剪贴板", - parameters: "text: 要复制的文本内容" - ) { args in - guard let text = args["text"] as? String else { - return "{\"success\": false, \"error\": \"缺少 text 参数\"}" - } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - return "{\"success\": true, \"copied_length\": \(text.count)}" - }) - - // ── Device ── - register(RegisteredTool( - name: "device-info", - description: "获取设备型号、系统版本、内存、处理器信息", - parameters: "无" - ) { _ in - let info = ProcessInfo.processInfo - let hostname = Host.current().localizedName ?? "Unknown" - let osVersion = info.operatingSystemVersionString - let physicalMemory = info.physicalMemory / (1024 * 1024 * 1024) - let processorCount = info.processorCount - - var size: size_t = 0 - sysctlbyname("hw.model", nil, &size, nil, 0) - var model = [CChar](repeating: 0, count: size) - sysctlbyname("hw.model", &model, &size, nil, 0) - let modelStr = String(cString: model) - - return "{\"success\": true, \"name\": \"\(hostname)\", \"model\": \"\(modelStr)\", \"system\": \"macOS \(osVersion)\", \"memory_gb\": \(physicalMemory), \"processors\": \(processorCount)}" - }) - - // ── Text ── - register(RegisteredTool( - name: "calculate-hash", - description: "计算文本的哈希值", - parameters: "text: 要计算哈希的文本" - ) { args in - guard let text = args["text"] as? String else { - return "{\"success\": false, \"error\": \"缺少 text 参数\"}" - } - let hash = text.hashValue - return "{\"success\": true, \"input\": \"\(jsonEscape(text))\", \"hash\": \(hash)}" - }) - - register(RegisteredTool( - name: "text-reverse", - description: "翻转文本", - parameters: "text: 要翻转的文本" - ) { args in - guard let text = args["text"] as? String else { - return "{\"success\": false, \"error\": \"缺少 text 参数\"}" - } - let reversed = String(text.reversed()) - return "{\"success\": true, \"original\": \"\(jsonEscape(text))\", \"reversed\": \"\(jsonEscape(reversed))\"}" - }) - } -} - -// MARK: - Helpers - -func jsonEscape(_ str: String) -> String { - str.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\t", with: "\\t") -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/UI/ChatModels.swift b/PhoneClawMac/Sources/PhoneClawMac/UI/ChatModels.swift deleted file mode 100644 index f10af00..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/UI/ChatModels.swift +++ /dev/null @@ -1,101 +0,0 @@ -import SwiftUI - -// MARK: - 显示模型(跨平台共享) - -/// 单个 Skill 卡片数据 -struct SkillCard: Identifiable, Equatable { - let id: UUID - var skillName: String - var skillStatus: String? // "identified", "loaded", "executing", "done" - var toolName: String? // 正在执行的具体 Tool 名(如 "device-info") -} - -/// AI 回复块:多张 skill 卡片 + 思考动画 + 回复文本 -struct ResponseBlock: Identifiable, Equatable { - let id: UUID - var skills: [SkillCard] = [] - var responseText: String? - var isThinking: Bool -} - -/// 聊天列表的统一显示项 -enum DisplayItem: Identifiable { - case user(ChatMessage) - case response(ResponseBlock) - - var id: UUID { - switch self { - case .user(let msg): return msg.id - case .response(let block): return block.id - } - } -} - -// MARK: - Messages → DisplayItems 转换(跨平台共享) - -/// 用于纯思考占位的稳定 ID -private let thinkingPlaceholderID = UUID() - -func buildDisplayItems(from messages: [ChatMessage], isProcessing: Bool) -> [DisplayItem] { - var items: [DisplayItem] = [] - var block: ResponseBlock? = nil - - func flush() { - if let b = block { items.append(.response(b)); block = nil } - } - - for msg in messages { - switch msg.role { - case .user: - flush() - items.append(.user(msg)) - case .system: - if let name = msg.skillName { - if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) } - - let content = msg.content - // 查找已有的同名卡片 → 更新状态;否则新建 - if let idx = block?.skills.firstIndex(where: { $0.skillName == name }) { - if content.hasPrefix("executing:") { - block?.skills[idx].skillStatus = "executing" - block?.skills[idx].toolName = String(content.dropFirst("executing:".count)) - } else { - block?.skills[idx].skillStatus = content - } - } else { - var card = SkillCard(id: msg.id, skillName: name) - if content.hasPrefix("executing:") { - card.skillStatus = "executing" - card.toolName = String(content.dropFirst("executing:".count)) - } else { - card.skillStatus = content - } - block?.skills.append(card) - } - } - case .skillResult: - break - case .assistant: - if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) } - if msg.content != "▍" && !msg.content.isEmpty { - block?.responseText = msg.content - } - } - } - - if var b = block, !b.skills.isEmpty || b.responseText != nil { - if isProcessing && !b.skills.isEmpty && b.responseText == nil { - b.isThinking = true - } - items.append(.response(b)) - } - - if isProcessing { - let hasAI = block.map { !$0.skills.isEmpty || $0.responseText != nil } ?? false - if !hasAI { - items.append(.response(ResponseBlock(id: thinkingPlaceholderID, isThinking: true))) - } - } - - return items -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/UI/ConfigurationsView.swift b/PhoneClawMac/Sources/PhoneClawMac/UI/ConfigurationsView.swift deleted file mode 100644 index 31ce5e0..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/UI/ConfigurationsView.swift +++ /dev/null @@ -1,244 +0,0 @@ -import SwiftUI - -// MARK: - Configurations 弹窗(macOS 版,适配 Theme 暖色系) - -struct ConfigurationsView: View { - @Bindable var engine: AgentEngine - @Environment(\.dismiss) private var dismiss - @State private var selectedTab = 0 // 0=Model Configs, 1=System Prompt - - // 本地编辑状态(确认后才应用) - @State private var maxTokens: Double = 4000 - @State private var topK: Double = 64 - @State private var topP: Double = 0.95 - @State private var temperature: Double = 1.0 - @State private var selectedBackend = 1 // 0=CPU, 1=GPU - @State private var systemPrompt: String = "" - - var body: some View { - VStack(spacing: 0) { - // 标题 - Text("Configurations") - .font(.title2.weight(.bold)) - .foregroundStyle(Theme.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - - // Tab 切换 - HStack(spacing: 0) { - tabButton("Model Configs", tag: 0) - tabButton("System Prompt", tag: 1) - } - .padding(.horizontal) - - Rectangle().fill(Theme.border).frame(height: 1) - - Group { - if selectedTab == 0 { - modelConfigsTab - } else { - systemPromptTab - } - } - .frame(height: 360) - - Rectangle().fill(Theme.border).frame(height: 1) - - // 底部按钮 - HStack(spacing: 20) { - Spacer() - Button("Cancel") { dismiss() } - .foregroundStyle(Theme.textSecondary) - .buttonStyle(.plain) - Button("OK") { - applySettings() - dismiss() - } - .font(.body.weight(.semibold)) - .foregroundStyle(Theme.accent) - .padding(.horizontal, 24) - .padding(.vertical, 8) - .background(Theme.accent.opacity(0.15), in: Capsule()) - .buttonStyle(.plain) - } - .padding() - } - .frame(width: 420) - .background(Theme.bgElevated) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .preferredColorScheme(.dark) - .onAppear { loadCurrentSettings() } - } - - // MARK: - Tab 按钮 - - private func tabButton(_ title: String, tag: Int) -> some View { - Button { - withAnimation(.easeInOut(duration: 0.2)) { selectedTab = tag } - } label: { - VStack(spacing: 8) { - Text(title) - .font(.subheadline.weight(selectedTab == tag ? .semibold : .regular)) - .foregroundStyle(selectedTab == tag ? Theme.textPrimary : Theme.textTertiary) - - Rectangle() - .fill(selectedTab == tag ? Theme.accent : .clear) - .frame(height: 2) - } - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - } - - // MARK: - Model Configs - - private var modelConfigsTab: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - configSlider( - title: "Max Tokens", - value: $maxTokens, - range: 128...8192, - displayValue: "\(Int(maxTokens))" - ) - configSlider( - title: "TopK", - value: $topK, - range: 1...128, - displayValue: "\(Int(topK))" - ) - configSlider( - title: "TopP", - value: $topP, - range: 0...1, - displayValue: String(format: "%.2f", topP) - ) - configSlider( - title: "Temperature", - value: $temperature, - range: 0...2, - displayValue: String(format: "%.2f", temperature) - ) - - // Accelerator - VStack(alignment: .leading, spacing: 8) { - Text("Choose accelerator") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary) - - HStack(spacing: 0) { - acceleratorButton("CPU", tag: 0) - acceleratorButton("GPU", tag: 1) - } - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 8)) - } - } - .padding() - } - .frame(maxHeight: 360) - } - - // MARK: - System Prompt - - private var systemPromptTab: some View { - VStack(alignment: .leading, spacing: 12) { - TextEditor(text: $systemPrompt) - .font(.system(size: 13)) - .foregroundStyle(Theme.textPrimary) - .scrollContentBackground(.hidden) - .padding(12) - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Theme.border, lineWidth: 1) - ) - .frame(maxHeight: 280) - - Button("Restore default") { - systemPrompt = engine.defaultSystemPrompt - } - .font(.subheadline) - .foregroundStyle(Theme.accent) - .buttonStyle(.plain) - } - .padding() - } - - - - // MARK: - 配置 Slider - - private func configSlider( - title: String, - value: Binding, - range: ClosedRange, - displayValue: String - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.subheadline) - .foregroundStyle(Theme.textSecondary) - - HStack(spacing: 12) { - Slider(value: value, in: range) - .tint(Theme.accent) - - Text(displayValue) - .font(.body.monospaced()) - .foregroundStyle(Theme.textPrimary.opacity(0.8)) - .frame(width: 56) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 6)) - } - } - } - - // MARK: - Accelerator 按钮 - - private func acceleratorButton(_ title: String, tag: Int) -> some View { - Button { - selectedBackend = tag - } label: { - Text(title) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.textPrimary) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background( - selectedBackend == tag ? Theme.bgHover : .clear, - in: RoundedRectangle(cornerRadius: 6) - ) - } - .buttonStyle(.plain) - } - - // MARK: - 加载 / 应用 - - private func loadCurrentSettings() { - maxTokens = Double(engine.config.maxTokens) - topK = Double(engine.config.topK) - topP = engine.config.topP - temperature = engine.config.temperature - selectedBackend = engine.config.useGPU ? 1 : 0 - systemPrompt = engine.config.systemPrompt - } - - private func applySettings() { - let oldBackend = engine.config.useGPU - engine.config.maxTokens = Int(maxTokens) - engine.config.topK = Int(topK) - engine.config.topP = topP - engine.config.temperature = temperature - engine.config.useGPU = selectedBackend == 1 - engine.config.systemPrompt = systemPrompt - - // 同步采样参数到 LLM(下次生成立即生效) - engine.applySamplingConfig() - - // 如果 backend 变了,需要重载模型 - if oldBackend != engine.config.useGPU { - engine.reloadModel() - } - } -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/UI/ContentView.swift b/PhoneClawMac/Sources/PhoneClawMac/UI/ContentView.swift deleted file mode 100644 index 65f258b..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/UI/ContentView.swift +++ /dev/null @@ -1,487 +0,0 @@ -import SwiftUI - -// MARK: - 主入口 - -struct ContentView: View { - @State private var engine = AgentEngine() - @State private var inputText = "" - @State private var showConfigurations = false - @State private var showSkillsManager = false - /// 记录每个 skill 卡片的展开状态(key = ResponseBlock.id) - @State private var expandedSkills: Set = [] - - private var displayItems: [DisplayItem] { - buildDisplayItems(from: engine.messages, isProcessing: engine.isProcessing) - } - - var body: some View { - ZStack { - Theme.bg.ignoresSafeArea() - - VStack(spacing: 0) { - topBar - - if engine.messages.isEmpty { - welcomeView - } else { - chatList - } - - if engine.messages.isEmpty { - skillChips.padding(.bottom, 8) - } - - inputBar - } - } - .preferredColorScheme(.dark) - .task { engine.setup() } - .sheet(isPresented: $showConfigurations) { - ConfigurationsView(engine: engine) - } - .sheet(isPresented: $showSkillsManager) { - SkillsManagerView(engine: engine) - } - } - - // MARK: - 聊天列表 - - private var chatList: some View { - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: Theme.chatSpacing) { - ForEach(displayItems) { item in - switch item { - case .user(let msg): - UserBubble(text: msg.content) - case .response(let block): - AIResponseView( - block: block, - expandedSkills: expandedSkills, - onToggle: { toggleExpand($0) } - ) - } - } - } - .padding(.horizontal, Theme.chatPadH) - .padding(.vertical, 20) - } - .scrollIndicators(.hidden) - .onChange(of: engine.messages.count) { scrollTo(proxy) } - .onChange(of: engine.isProcessing) { scrollTo(proxy) } - } - } - - private func scrollTo(_ proxy: ScrollViewProxy) { - guard let last = displayItems.last else { return } - withAnimation(.easeOut(duration: 0.3)) { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - - private func toggleExpand(_ id: UUID) { - withAnimation(.easeInOut(duration: 0.25)) { - if expandedSkills.contains(id) { - expandedSkills.remove(id) - } else { - expandedSkills.insert(id) - } - } - } - - // MARK: - 顶部栏 - - private var topBar: some View { - HStack(spacing: 0) { - // 左:新会话 - Button(action: { engine.clearMessages() }) { - Image(systemName: "square.and.pencil") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 34, height: 34) - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(.plain) - - Spacer() - - // 中:模型状态 - HStack(spacing: 6) { - Circle() - .fill(engine.llm.isLoaded ? Theme.accentGreen : Theme.accent) - .frame(width: 6, height: 6) - Text(engine.llm.isLoaded ? "Gemma 4 E4B" : engine.llm.statusMessage) - .font(.system(size: 12, weight: .medium, design: .rounded)) - .foregroundStyle(Theme.textSecondary) - } - - Spacer() - - // 右:Skills + 设置 - HStack(spacing: 6) { - Button(action: { showSkillsManager = true }) { - Image(systemName: "puzzlepiece.extension") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 34, height: 34) - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(.plain) - - Button(action: { showConfigurations = true }) { - Image(systemName: "slider.horizontal.3") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 34, height: 34) - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, Theme.inputPadH) - .padding(.vertical, 10) - } - - // MARK: - 欢迎页 - - private var welcomeView: some View { - VStack(spacing: 0) { - Spacer() - ZStack { - Circle().fill(Theme.accentSubtle).frame(width: 60, height: 60) - Image(systemName: "sparkles") - .font(.system(size: 24, weight: .medium)) - .foregroundStyle(Theme.accent) - } - Text("PhoneClaw") - .font(.system(size: 26, weight: .semibold, design: .rounded)) - .foregroundStyle(Theme.textPrimary) - .padding(.top, 16) - Text("On-device AI Agent") - .font(.system(size: 14)) - .foregroundStyle(Theme.textTertiary) - .padding(.top, 4) - Spacer() - } - } - - // MARK: - Skill 快捷标签 - - private var skillChips: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(engine.enabledSkillInfos, id: \.name) { skill in - Button { - inputText = skill.samplePrompt - Task { await send() } - } label: { - HStack(spacing: 5) { - Image(systemName: skill.icon).font(.system(size: 11)) - Text(skill.displayName).font(.system(size: 12, weight: .medium)) - } - .foregroundStyle(Theme.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 20) - .strokeBorder(Theme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, Theme.chatPadH) - } - } - - // MARK: - 输入栏 - - private var inputBar: some View { - HStack(spacing: 10) { - #if os(macOS) - TextField("Message…", text: $inputText) - .textFieldStyle(.plain) - .font(.system(size: 15)) - .foregroundStyle(Theme.textPrimary) - .padding(.horizontal, 16) - .padding(.vertical, 11) - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 22)) - .overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(Theme.border, lineWidth: 1)) - .onSubmit { Task { await send() } } - #else - TextField("Message…", text: $inputText, axis: .vertical) - .lineLimit(1...5) - .font(.system(size: 15)) - .foregroundStyle(Theme.textPrimary) - .padding(.horizontal, 16) - .padding(.vertical, 11) - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 22)) - .overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(Theme.border, lineWidth: 1)) - .onSubmit { Task { await send() } } - #endif - - Button { Task { await send() } } label: { - Image(systemName: "arrow.up") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(canSend ? Theme.bg : Theme.textTertiary) - .frame(width: 34, height: 34) - .background(canSend ? Theme.accent : Theme.bgElevated, in: Circle()) - .overlay(Circle().strokeBorder(canSend ? .clear : Theme.border, lineWidth: 1)) - } - .buttonStyle(.plain) - .disabled(!canSend) - .animation(.easeInOut(duration: 0.15), value: canSend) - } - .padding(.horizontal, Theme.inputPadH) - .padding(.vertical, 14) - .background(Theme.bg) - } - - private var canSend: Bool { - !inputText.trimmingCharacters(in: .whitespaces).isEmpty - && !engine.isProcessing && engine.llm.isLoaded - } - - private func send() async { - let text = inputText - inputText = "" - await engine.processInput(text) - } -} - -// MARK: - 用户气泡 - -struct UserBubble: View { - let text: String - var body: some View { - HStack { - Spacer(minLength: Theme.bubbleMinSpacer) - Text(text) - .font(.system(size: 15)) - .foregroundStyle(Theme.userText) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Theme.userBubble, in: UserBubbleShape()) - } - } -} - -struct UserBubbleShape: Shape { - func path(in rect: CGRect) -> Path { - let r: CGFloat = 18, sr: CGFloat = 4 - return Path { p in - p.move(to: CGPoint(x: rect.minX + r, y: rect.minY)) - p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) - p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), - tangent2End: CGPoint(x: rect.maxX, y: rect.minY + r), radius: r) - p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - sr)) - p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), - tangent2End: CGPoint(x: rect.maxX - sr, y: rect.maxY), radius: sr) - p.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY)) - p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), - tangent2End: CGPoint(x: rect.minX, y: rect.maxY - r), radius: r) - p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) - p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), - tangent2End: CGPoint(x: rect.minX + r, y: rect.minY), radius: r) - } - } -} - -// MARK: - AI 回复 - -struct AIResponseView: View { - let block: ResponseBlock - let expandedSkills: Set - let onToggle: (UUID) -> Void - - private var hasSkill: Bool { !block.skills.isEmpty } - private var isPureThinking: Bool { !hasSkill && block.responseText == nil && block.isThinking } - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 12) { - // ★ 纯思考状态(无 skill、无文字)→ 第一张卡片出现后淡出 - if isPureThinking { - ThinkingIndicator() - .padding(.leading, 12) - .padding(.vertical, 10) - } - - // ★ 所有 Skill 卡片(支持多张) - ForEach(block.skills) { card in - SkillCardView( - card: card, - isExpanded: expandedSkills.contains(card.id), - onToggle: { onToggle(card.id) } - ) - .transition(.opacity.combined(with: .scale(scale: 0.95))) - } - - // Skill 完成后等待 follow-up 文字 - if hasSkill && block.isThinking && block.responseText == nil { - ThinkingIndicator() - .padding(.leading, 12) - } - - // 回复文本 - if let text = block.responseText { - Text(text) - .font(.system(size: 15)) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .lineSpacing(5) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - .padding(.leading, 4) - .animation(nil, value: text) - } - } - .animation(.easeInOut(duration: 0.3), value: block.skills.count) - - Spacer(minLength: Theme.aiMinSpacer) - } - } -} - -// MARK: - 单个 Skill 卡片(4 步进度) - -struct SkillCardView: View { - let card: SkillCard - let isExpanded: Bool - let onToggle: () -> Void - - private var isSkillDone: Bool { card.skillStatus == "done" } - - private var currentStep: Int { - switch card.skillStatus { - case "identified": return 0 - case "loaded": return 1 - case let s where s?.hasPrefix("executing") == true: return 2 - case "done": return 3 - default: return 0 - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // 卡片头部 - HStack(spacing: 10) { - // 图标:spinner → 扎手 - ZStack { - Image(systemName: "wrench.and.screwdriver.fill") - .font(.system(size: 13)) - .foregroundStyle(Theme.accent) - .frame(width: 26, height: 26) - .background(Theme.accentSubtle, in: RoundedRectangle(cornerRadius: 7)) - .opacity(isSkillDone ? 1 : 0) - - SpinnerIcon() - .frame(width: 26, height: 26) - .opacity(isSkillDone ? 0 : 1) - } - .animation(.easeInOut(duration: 0.3), value: isSkillDone) - - Text(isSkillDone ? "Used \"\(card.skillName)\"" : "Running \"\(card.skillName)\"…") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Theme.textPrimary) - .lineLimit(1) - .contentTransition(.opacity) - .animation(.easeInOut(duration: 0.2), value: isSkillDone) - - Spacer() - - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - .rotationEffect(.degrees(isExpanded ? -180 : 0)) - .animation(.easeInOut(duration: 0.25), value: isExpanded) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .contentShape(Rectangle()) - .onTapGesture { onToggle() } - - // 展开:4 步进度 - if isExpanded { - Rectangle().fill(Theme.borderSubtle).frame(height: 1) - - VStack(alignment: .leading, spacing: 6) { - stepRow(label: "识别能力: \(card.skillName)", - done: currentStep > 0, - active: currentStep == 0) - stepRow(label: "加载 Skill 指令", - done: currentStep > 1, - active: currentStep == 1) - stepRow(label: card.toolName != nil ? "执行 \(card.toolName!)" : "执行工具", - done: currentStep > 2, - active: currentStep == 2) - stepRow(label: "生成回复", - done: isSkillDone, - active: false) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .animation(.easeInOut(duration: 0.2), value: currentStep) - } - } - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.border, lineWidth: 1)) - } - - private func stepRow(label: String, done: Bool, active: Bool = false) -> some View { - HStack(spacing: 8) { - Group { - if done { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 11)) - .foregroundStyle(Theme.accentGreen) - } else if active { - ProgressView().controlSize(.mini).tint(Theme.textTertiary) - } else { - Circle().fill(Theme.textTertiary.opacity(0.3)).frame(width: 6, height: 6) - } - } - .frame(width: 14, height: 14) - - Text(label) - .font(.system(size: 12)) - .foregroundStyle(done ? Theme.textSecondary : Theme.textTertiary) - } - } -} - -// MARK: - 旋转 Spinner - -struct SpinnerIcon: View { - @State private var rotating = false - var body: some View { - Image(systemName: "asterisk") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.textTertiary) - .rotationEffect(.degrees(rotating ? 360 : 0)) - .animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: rotating) - .onAppear { rotating = true } - } -} - -// MARK: - 思考动画 - -struct ThinkingIndicator: View { - @State private var active = 0 - let timer = Timer.publish(every: 0.45, on: .main, in: .common).autoconnect() - - var body: some View { - HStack(spacing: 5) { - ForEach(0..<3, id: \.self) { i in - Circle() - .fill(Theme.textTertiary) - .frame(width: 6, height: 6) - .opacity(active == i ? 1.0 : 0.3) - .scaleEffect(active == i ? 1.0 : 0.75) - .animation(.easeInOut(duration: 0.35), value: active) - } - } - .frame(height: 20) - .onReceive(timer) { _ in active = (active + 1) % 3 } - } -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/UI/SkillsManagerView.swift b/PhoneClawMac/Sources/PhoneClawMac/UI/SkillsManagerView.swift deleted file mode 100644 index 4c01bfc..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/UI/SkillsManagerView.swift +++ /dev/null @@ -1,408 +0,0 @@ -import SwiftUI - -// MARK: - Skills 管理面板(独立面板) - -struct SkillsManagerView: View { - @Bindable var engine: AgentEngine - @Environment(\.dismiss) private var dismiss - @State private var expandedSkills: Set = [] - - private var enabledCount: Int { engine.skillEntries.filter(\.isEnabled).count } - - var body: some View { - VStack(spacing: 0) { - // 标题栏 - HStack { - Text("Skills") - .font(.title2.weight(.bold)) - .foregroundStyle(Theme.textPrimary) - - Spacer() - - HStack(spacing: 12) { - Button { engine.reloadSkills() } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.accent) - } - .buttonStyle(.plain) - .help("Reload all skills from disk") - - Button("Enable all") { engine.setAllSkills(enabled: true) } - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.accent) - .buttonStyle(.plain) - Button("Disable all") { engine.setAllSkills(enabled: false) } - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.textTertiary) - .buttonStyle(.plain) - } - } - .padding(.horizontal, 20) - .padding(.top, 18) - .padding(.bottom, 12) - - Rectangle().fill(Theme.border).frame(height: 1) - - // Skill 列表 - ScrollView { - VStack(spacing: 10) { - ForEach(engine.skillEntries.indices, id: \.self) { i in - SkillDetailCard( - entry: $engine.skillEntries[i], - isExpanded: expandedSkills.contains(engine.skillEntries[i].id), - onToggleExpand: { toggleExpand(engine.skillEntries[i].id) }, - onSave: { content in - try? engine.skillLoader.saveSkill(skillId: engine.skillEntries[i].id, content: content) - engine.reloadSkills() - } - ) - } - } - .padding(16) - } - - Rectangle().fill(Theme.border).frame(height: 1) - - // 底部 - HStack { - Text("\(enabledCount)/\(engine.skillEntries.count) enabled") - .font(.system(size: 12)) - .foregroundStyle(Theme.textTertiary) - - Spacer() - - Button("Done") { dismiss() } - .font(.body.weight(.semibold)) - .foregroundStyle(Theme.accent) - .padding(.horizontal, 24) - .padding(.vertical, 7) - .background(Theme.accent.opacity(0.15), in: Capsule()) - .buttonStyle(.plain) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - } - .frame(width: 520, height: 640) - .background(Theme.bg) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .preferredColorScheme(.dark) - } - - private func toggleExpand(_ id: String) { - withAnimation(.easeInOut(duration: 0.25)) { - if expandedSkills.contains(id) { - expandedSkills.remove(id) - } else { - expandedSkills.insert(id) - } - } - } -} - -// MARK: - 单个 Skill 详情卡片(三层架构展示 + 编辑) - -struct SkillDetailCard: View { - @Binding var entry: SkillEntry - let isExpanded: Bool - let onToggleExpand: () -> Void - let onSave: (String) -> Void - - @State private var isEditing = false - @State private var editText = "" - @State private var showSource = false - @State private var saveFlash = false - - /// L2: SKILL.md 的 Markdown body(指令体,注入 LLM) - private var skillBody: String? { - guard let url = entry.filePath, - let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } - guard let regex = try? NSRegularExpression( - pattern: "^---\\s*\\n.*?\\n---\\s*\\n(.*)$", - options: .dotMatchesLineSeparators - ) else { return nil } - let range = NSRange(content.startIndex..., in: content) - if let match = regex.firstMatch(in: content, range: range), - let bodyRange = Range(match.range(at: 1), in: content) { - return String(content[bodyRange]).trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - /// 完整 SKILL.md 原始内容 - private var rawContent: String { - guard let url = entry.filePath, - let content = try? String(contentsOf: url, encoding: .utf8) else { return "" } - return content - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // ── 头部 ── - HStack(spacing: 10) { - Image(systemName: entry.icon) - .font(.system(size: 15)) - .foregroundStyle(entry.isEnabled ? Theme.accent : Theme.textTertiary) - .frame(width: 32, height: 32) - .background( - entry.isEnabled ? Theme.accentSubtle : Theme.textTertiary.opacity(0.08), - in: RoundedRectangle(cornerRadius: 8) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - Text(entry.description) - .font(.system(size: 11)) - .foregroundStyle(Theme.textTertiary) - .lineLimit(1) - } - - Spacer() - - Toggle("", isOn: $entry.isEnabled) - .toggleStyle(.switch) - .controlSize(.small) - .labelsHidden() - - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - .rotationEffect(.degrees(isExpanded ? -180 : 0)) - .animation(.easeInOut(duration: 0.25), value: isExpanded) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .contentShape(Rectangle()) - .onTapGesture { onToggleExpand() } - - // ── 展开详情:三层架构 ── - if isExpanded { - Rectangle().fill(Theme.border).frame(height: 1) - - VStack(alignment: .leading, spacing: 14) { - - // ━━ L1: TOOLS(原生工具 · ToolRegistry) ━━ - if !entry.tools.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Text("TOOLS") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Theme.textTertiary) - .kerning(1) - Text("· ToolRegistry") - .font(.system(size: 9, design: .monospaced)) - .foregroundStyle(Theme.textTertiary.opacity(0.6)) - } - - ForEach(entry.tools, id: \.name) { tool in - HStack(alignment: .top, spacing: 8) { - // ★ 注册状态标注 - Image(systemName: ToolRegistry.shared.hasToolNamed(tool.name) ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .font(.system(size: 10)) - .foregroundStyle(ToolRegistry.shared.hasToolNamed(tool.name) ? Theme.accentGreen : .orange) - .frame(width: 14) - .padding(.top, 2) - - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(tool.name) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(Theme.textPrimary) - // ★ 注册/未注册 badge - Text(ToolRegistry.shared.hasToolNamed(tool.name) ? "registered" : "missing") - .font(.system(size: 8, weight: .semibold, design: .monospaced)) - .foregroundStyle(ToolRegistry.shared.hasToolNamed(tool.name) ? Theme.accentGreen : .orange) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - (ToolRegistry.shared.hasToolNamed(tool.name) ? Theme.accentGreen : Color.orange).opacity(0.12), - in: Capsule() - ) - } - Text(tool.description) - .font(.system(size: 11)) - .foregroundStyle(Theme.textSecondary) - if tool.parameters != "无" { - HStack(spacing: 4) { - Text("参数:") - .foregroundStyle(Theme.textTertiary) - Text(tool.parameters) - .foregroundStyle(Theme.textSecondary) - } - .font(.system(size: 10)) - } - } - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 6)) - } - } - } - - // ━━ EXAMPLE ━━ - VStack(alignment: .leading, spacing: 4) { - Text("EXAMPLE") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Theme.textTertiary) - .kerning(1) - - Text("\"\(entry.samplePrompt)\"") - .font(.system(size: 12, design: .rounded)) - .foregroundStyle(Theme.textSecondary) - .italic() - } - - // ━━ L2: INSTRUCTIONS(Markdown body · 注入 LLM 上下文) ━━ - if let body = skillBody { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Text("INSTRUCTIONS") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Theme.textTertiary) - .kerning(1) - Text("· LLM Context") - .font(.system(size: 9, design: .monospaced)) - .foregroundStyle(Theme.textTertiary.opacity(0.6)) - } - - Text(body) - .font(.system(size: 11)) - .foregroundStyle(Theme.textSecondary) - .lineSpacing(3) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 6)) - } - } - - // ━━ SKILL.MD 源文件(查看 / 编辑 / 保存 / 热重载) ━━ - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Text("SKILL.MD") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Theme.textTertiary) - .kerning(1) - - if let path = entry.filePath?.path { - Text(path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) - .font(.system(size: 8, design: .monospaced)) - .foregroundStyle(Theme.textTertiary.opacity(0.5)) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer() - - if isEditing { - Button("Cancel") { - isEditing = false - } - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Theme.textTertiary) - .buttonStyle(.plain) - - Button { - onSave(editText) - isEditing = false - showSource = true - saveFlash = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - saveFlash = false - } - } label: { - HStack(spacing: 3) { - Image(systemName: "arrow.down.doc") - .font(.system(size: 9)) - Text("Save & Reload") - .font(.system(size: 11, weight: .semibold)) - } - .foregroundStyle(Theme.bg) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(Theme.accent, in: Capsule()) - } - .buttonStyle(.plain) - } else { - Button { - editText = rawContent - isEditing = true - showSource = true - } label: { - HStack(spacing: 3) { - Image(systemName: "pencil") - .font(.system(size: 9)) - Text("Edit") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(Theme.accent) - } - .buttonStyle(.plain) - - Button { - if !showSource { editText = rawContent } - showSource.toggle() - } label: { - HStack(spacing: 3) { - Image(systemName: showSource ? "eye.slash" : "eye") - .font(.system(size: 9)) - Text(showSource ? "Hide" : "View") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(Theme.textSecondary) - } - .buttonStyle(.plain) - } - } - - if saveFlash { - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 10)) - Text("Saved & reloaded") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(Theme.accentGreen) - .transition(.opacity) - } - - if showSource || isEditing { - if isEditing { - TextEditor(text: $editText) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(Theme.textPrimary) - .scrollContentBackground(.hidden) - .frame(minHeight: 180, maxHeight: 300) - .padding(6) - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Theme.accent.opacity(0.4), lineWidth: 1) - ) - } else { - ScrollView { - Text(editText) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(Theme.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - .frame(maxHeight: 200) - .padding(6) - .background(Theme.bg, in: RoundedRectangle(cornerRadius: 6)) - } - } - } - } - .padding(12) - } - } - .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.border, lineWidth: 1)) - .opacity(entry.isEnabled ? 1.0 : 0.6) - .animation(.easeInOut(duration: 0.2), value: entry.isEnabled) - .animation(.easeInOut(duration: 0.3), value: saveFlash) - } -} diff --git a/PhoneClawMac/Sources/PhoneClawMac/UI/Theme.swift b/PhoneClawMac/Sources/PhoneClawMac/UI/Theme.swift deleted file mode 100644 index 6935865..0000000 --- a/PhoneClawMac/Sources/PhoneClawMac/UI/Theme.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI - -// MARK: - PhoneClaw 设计系统(Claude 风格) -// 跨平台共享:macOS + iOS - -struct Theme { - // MARK: 背景 - static let bg = Color(hex: "1A1915") // 深暖棕 - static let bgElevated = Color(hex: "24221D") // 卡片/输入框 - static let bgHover = Color(hex: "2E2B25") // hover / pressed - - // MARK: 文字 - static let textPrimary = Color(hex: "ECECEA") // 主文字 暖白 - static let textSecondary = Color(hex: "A8A8A0") // 次要 - static let textTertiary = Color(hex: "6B6B63") // 辅助/placeholder - - // MARK: 强调色 - static let accent = Color(hex: "D4A574") // 沙金/赭石 - static let accentSubtle = Color(hex: "D4A574").opacity(0.12) - static let accentGreen = Color(hex: "7CB87C") // 成功/在线 - - // MARK: 用户气泡 - static let userBubble = Color(hex: "D4A574") - static let userText = Color(hex: "1A1915") - - // MARK: 边框 - static let border = Color(hex: "3A3732") - static let borderSubtle = Color(hex: "2E2B25") - - // MARK: 响应式间距 - #if os(macOS) - static let chatPadH: CGFloat = 24 - static let chatSpacing: CGFloat = 24 - static let inputPadH: CGFloat = 20 - static let bubbleMinSpacer: CGFloat = 80 - static let aiMinSpacer: CGFloat = 40 - #else - static let chatPadH: CGFloat = 16 - static let chatSpacing: CGFloat = 20 - static let inputPadH: CGFloat = 16 - static let bubbleMinSpacer: CGFloat = 60 - static let aiMinSpacer: CGFloat = 24 - #endif -} - -// MARK: - Hex Color(跨平台) - -extension Color { - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: (a, r, g, b) = (255, 0, 0, 0) - } - self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255, opacity: Double(a)/255) - } -} diff --git a/PhoneClaw/Skills/SkillLoader.swift b/Skills/SkillLoader.swift similarity index 100% rename from PhoneClaw/Skills/SkillLoader.swift rename to Skills/SkillLoader.swift diff --git a/PhoneClaw/Skills/Skills.swift b/Skills/Skills.swift similarity index 100% rename from PhoneClaw/Skills/Skills.swift rename to Skills/Skills.swift diff --git a/PhoneClaw/Skills/ToolRegistry.swift b/Skills/ToolRegistry.swift similarity index 100% rename from PhoneClaw/Skills/ToolRegistry.swift rename to Skills/ToolRegistry.swift diff --git a/TECH_PATH_ANALYSIS.md b/TECH_PATH_ANALYSIS.md deleted file mode 100644 index e3f26f4..0000000 --- a/TECH_PATH_ANALYSIS.md +++ /dev/null @@ -1,117 +0,0 @@ -# PhoneClaw 技术路径分析 - -> 基于对 Google AI Edge Gallery、LiteRT-LM、Gemma 4 E4B 的实际代码和文档研究 - -## 关键发现 - -### 1. 你手机上跑的 App = Google AI Edge Gallery -- App Store: https://apps.apple.com/us/app/google-ai-edge-gallery/id6749645337 -- GitHub: https://github.com/google-ai-edge/gallery -- **但 iOS 源码未开源**(GitHub 上只有 Android Kotlin 代码,占 91%) -- iOS 版由 Google 内部开发,未公开 Swift 源码 - -### 2. Gemma 4 E4B 的推理引擎 = LiteRT-LM -- GitHub: https://github.com/google-ai-edge/LiteRT-LM -- 语言: C++ (76.5%), 有 C API 绑定 -- **iOS 支持**: 有(Metal GPU 加速),但 **Swift API 标注 "Coming Soon"** -- 当前可用: C/C++ API → 可通过 Swift Bridging Header 调用 -- 内置 Function Calling / Tool Use 支持(核心!) - -### 3. Gallery 的 Agent Skills 系统 -- skills/ 目录包含模块化技能定义(平台无关的 Markdown 文件) -- 技能格式: SKILL.md (YAML frontmatter + 指令) -- Gallery 支持**通过 URL 加载自定义 Skill** -- Function_Calling_Guide.md 详细说明了工具定义方式 - ---- - -## 三条可行路径 - -### 路径 A:在 Gallery App 上加载自定义 Skill ⭐ 最快 -``` -成本: 0 代码 时间: 30 分钟 -原理: Gallery App 原生支持从 URL 加载 Skill -你只需要写 SKILL.md 文件,托管在 GitHub 上 -``` - -**优点**: 零开发成本,直接用 E4B,Gallery 已处理好所有推理/UI/权限 -**缺点**: Gallery 是沙箱 App,不能调用你自定义的 iOS API(相机/日历等) -**适合**: 验证 Skill 提示词和交互设计 - ---- - -### 路径 B:Fork Gallery + LiteRT-LM C API → Swift 自定义 App ⭐⭐ 推荐 -``` -成本: 中等 时间: 1-2 周 -原理: 用 LiteRT-LM 的 C API 从 Swift 调用 E4B 推理 - 自己实现 Skill Router + iOS API 调用 -``` - -**技术栈**: -``` -Swift App - ├── LiteRT-LM C API (via Bridging Header) - │ └── 加载 .litertlm 模型文件 - │ └── Conversation API (含 tool use) - ├── Skill Router (Swift) - │ └── 解析 function call → 执行 iOS API - └── iOS Skills (Swift) - └── 相机/剪贴板/日历/健康... -``` - -**LiteRT-LM C API 核心接口**: -```c -// 创建引擎 -LiteRtLmEngine* engine = LiteRtLmEngineCreate(settings); - -// 创建对话 -LiteRtLmConversation* conv = LiteRtLmConversationCreate(engine); - -// 发送消息(含工具定义) -LiteRtLmConversationSendMessage(conv, message, callback); - -// 返回工具结果 -LiteRtLmConversationSendToolResult(conv, tool_call_id, result); -``` - -**优点**: 完全控制,能调用所有 iOS API,真正的端侧 Agent -**缺点**: 需要编译 LiteRT-LM 的 iOS 库(CMake/Bazel) - ---- - -### 路径 C:MediaPipe LLM Inference API (CocoaPods) -``` -成本: 中等 时间: 3-5 天 -原理: Google 的旧版 API,有 iOS CocoaPods 支持 -``` - -**Podfile**: -```ruby -pod 'MediaPipeTasksGenAI' -pod 'MediaPipeTasksGenAIC' -``` - -**优点**: 集成简单,CocoaPods 一键安装 -**缺点**: 较旧的 API,不确定是否支持 Gemma 4 E4B 格式(.litertlm) - Google 建议迁移到 LiteRT-LM - ---- - -## 推荐策略:A → B 渐进 - -### 阶段 1(今天):路径 A — Skill 验证 -- 写一个自定义 SKILL.md -- 在 Gallery App 中加载运行 -- 验证 E4B 的 function calling 能力和提示词 - -### 阶段 2(本周):路径 B — 搭建独立 App -- 编译 LiteRT-LM iOS 库 -- Swift Bridging Header 接入 C API -- 实现第一个真正有 iOS 权限调用的 Skill(clipboard_read) - ---- - -## 接下来你想先走哪一步? - -- **A**: 我现在就帮你写一个 SKILL.md,放到 Gallery 里测试 -- **B**: 直接开始搭建独立 App(编译 LiteRT-LM) diff --git a/PhoneClaw/UI/ChatModels.swift b/UI/ChatModels.swift similarity index 100% rename from PhoneClaw/UI/ChatModels.swift rename to UI/ChatModels.swift diff --git a/PhoneClaw/UI/ConfigurationsView.swift b/UI/ConfigurationsView.swift similarity index 100% rename from PhoneClaw/UI/ConfigurationsView.swift rename to UI/ConfigurationsView.swift diff --git a/PhoneClaw/UI/ContentView.swift b/UI/ContentView.swift similarity index 100% rename from PhoneClaw/UI/ContentView.swift rename to UI/ContentView.swift diff --git a/PhoneClaw/UI/SkillsManagerView.swift b/UI/SkillsManagerView.swift similarity index 100% rename from PhoneClaw/UI/SkillsManagerView.swift rename to UI/SkillsManagerView.swift diff --git a/PhoneClaw/UI/Theme.swift b/UI/Theme.swift similarity index 100% rename from PhoneClaw/UI/Theme.swift rename to UI/Theme.swift diff --git a/phoneclaw.code-workspace b/phoneclaw.code-workspace deleted file mode 100644 index 59b0725..0000000 --- a/phoneclaw.code-workspace +++ /dev/null @@ -1,16 +0,0 @@ -{ - "folders": [ - { - "name": "PhoneClaw", - "path": "." - } - ], - "settings": { - "git.ignoreLimitWarning": true, - "git.ignoredRepositories": [ - "LiteRT-LM", - "_mlx-swift-lm", - "_mediapipe-samples" - ] - } -} diff --git a/project.yml b/project.yml deleted file mode 100644 index 8e06dce..0000000 --- a/project.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: PhoneClaw -options: - bundleIdPrefix: com.phoneclaw - deploymentTarget: - iOS: "17.0" - xcodeVersion: "16.0" - createIntermediateGroups: true - -settings: - base: - SWIFT_VERSION: "5.9" - TARGETED_DEVICE_FAMILY: "1" # iPhone only - -targets: - PhoneClaw: - type: application - platform: iOS - sources: - - path: PhoneClaw - excludes: - - "*.h" - settings: - base: - INFOPLIST_FILE: PhoneClaw/Info.plist - CODE_SIGN_ENTITLEMENTS: PhoneClaw/PhoneClaw.entitlements - PRODUCT_BUNDLE_IDENTIFIER: com.zxw.phoneclaw2026 - MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: "1" - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - SWIFT_OBJC_BRIDGING_HEADER: PhoneClaw/PhoneClaw-Bridging-Header.h