refactor: flatten structure + remove dead code

- Move PhoneClaw/ contents to project root (no more nesting)
- Delete PhoneClawMac/ (nested SPM package, duplicated iOS code)
- Delete LLM/LocalLLMService.swift (dead MediaPipe implementation)
- Delete llm_inference_engine.h + PhoneClaw-Bridging-Header.h (unused)
- Delete Sources/ (empty dir), phoneclaw.code-workspace, TECH_PATH_ANALYSIS.md, project.yml
- Fix project.pbxproj: update INFOPLIST_FILE, CODE_SIGN_ENTITLEMENTS paths
- Fix project.pbxproj: remove SWIFT_OBJC_BRIDGING_HEADER setting
- Fix project.pbxproj: remove LocalLLMService.swift file reference
- Fix project.pbxproj: PhoneClaw group now uses name= instead of path=
This commit is contained in:
Kellyvv committed 2026-04-06 01:33:10 +08:00
1 parent d3f7dd78ca
commit 0f146d4e8f
51 files changed
+7 -4202

No files matched your search

File renamed without changes.
File renamed without changes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 332 KiB

File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Submodule
+1
Submodule LiteRT-LM added at 24a8bb189f.
File renamed without changes.
+6 -9
View File
@@ -50,7 +50,7 @@
4DD9808FE93325C3CE4CFA00 /* PhoneClawApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneClawApp.swift; sourceTree = "<group>"; };
52F30C6348B834E5D157205F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
571908036E6DCED1D58C1E9E /* Skills.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Skills.swift; sourceTree = "<group>"; };
772CB5029014926E172C0BF1 /* LocalLLMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLLMService.swift; sourceTree = "<group>"; };
AA2000000000000000000001 /* LLMEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMEngine.swift; sourceTree = "<group>"; };
AA2000000000000000000002 /* MLXLocalLLMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXLocalLLMService.swift; sourceTree = "<group>"; };
AA2000000000000000000003 /* Gemma4Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4Config.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
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;
-187
View File
@@ -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<String, Error>) -> 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 "推理失败"
}
}
}
}
-6
View File
@@ -1,6 +0,0 @@
//
// PhoneClaw-Bridging-Header.h
// 直接调用 LiteRT-LM C API
//
#include "llm_inference_engine.h"
-345
View File
@@ -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 <cstddef>
#else
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#endif
#ifdef __EMSCRIPTEN__
#include <functional>
#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<void*(uint64_t offset, uint64_t size, int mode)>;
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_
-33
View File
@@ -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",
]),
]
),
]
)
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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_
@@ -1,5 +0,0 @@
module CLiteRtLm {
header "include/engine.h"
link "LiteRtLmEngine"
export *
}
-2
View File
@@ -1,2 +0,0 @@
// Placeholder for CLiteRtLm C library
// The actual implementation is in libLiteRtLmEngine.dylib
@@ -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 加载该能力的详细指令:
<tool_call>
{"name": "load_skill", "arguments": {"skill": "能力名"}}
</tool_call>
不需要能力时直接回复。用中文回答,简洁实用。
"""
}
// 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("<tool_call>") {
detectedToolCall = true
return // 不转换消息角色,保持 assistant "▍" → 显示思考点
}
if !bufferFlushed {
let trimmed = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return }
if "<tool_call>".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<String?, Never>) 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("<tool_call>") {
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 "<tool_call>".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 = [
"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>",
"```json\\s*(\\{.*?\\})\\s*```",
"<function_call>\\s*(\\{.*?\\})\\s*</function_call>"
]
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
// ★ 截断:遇到 <tool_call> 只保留之前的内容
if let tcRange = result.range(of: "<tool_call>") {
result = String(result[result.startIndex..<tcRange.lowerBound])
}
// 截断:第一个 end-of-turn token
let endPatterns = ["<turn|>", "<end_of_turn>", "<eos>"]
for pat in endPatterns {
if let range = result.range(of: pat) {
result = String(result[result.startIndex..<range.lowerBound])
break
}
}
// 移除完整闭合的 <tool_call>...</tool_call> 块
if let regex = try? NSRegularExpression(pattern: "<tool_call>.*?</tool_call>", 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
// ★ 移除完整的 <tool_call>...</tool_call> 块
if let regex = try? NSRegularExpression(pattern: "<tool_call>.*?</tool_call>", options: .dotMatchesLineSeparators) {
result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
}
// ★ 截断:遇到未闭合的 <tool_call 也截断
if let tcRange = result.range(of: "<tool_call>") {
result = String(result[result.startIndex..<tcRange.lowerBound])
}
// 截断:第一个 end-of-turn token
let endPatterns = ["<turn|>", "<end_of_turn>", "<eos>"]
for pat in endPatterns {
if let range = result.range(of: pat) {
result = String(result[result.startIndex..<range.lowerBound])
break
}
}
// 清理所有 Gemma 4 特殊 token
result = result.replacingOccurrences(
of: "<\\|?[/a-z_]+\\|?>",
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..<lastOpen])
}
}
// 清理开头的 "model\n"
if result.hasPrefix("model\n") {
result = String(result.dropFirst(6))
} else if result == "model" {
result = ""
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -1,36 +0,0 @@
import SwiftUI
import AppKit
import Foundation
/// 输出到 stderr(GUI 进程的 stdout 不可见)
func log(_ items: Any...) {
let msg = items.map { String(describing: $0) }.joined(separator: " ")
FileHandle.standardError.write(Data((msg + "\n").utf8))
}
@main
struct PhoneClawMacApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 500, idealWidth: 600, minHeight: 700, idealHeight: 800)
}
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 600, height: 800)
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// 命令行启动 SwiftUI 必须手动设置,否则窗口不显示
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// 确保窗口在最前面
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
NSApp.windows.first?.makeKeyAndOrderFront(nil)
}
}
}
@@ -1,287 +0,0 @@
import Foundation
import CLiteRtLm
// MARK: - LiteRT-LM C API (engine.h) → Swift 桥接
//
// 使用 Session API(非 Conversation API),因为 PromptBuilder 已经
// 构建了完整的 Gemma 4 格式 prompt。Session API 直接处理原始文本。
@Observable
class LocalLLMService {
private var engine: OpaquePointer?
/// 当前活跃的 session(引擎同时只支持一个)
private var activeSession: OpaquePointer?
private let sessionLock = NSLock()
var isLoaded = false
var isGenerating = false
var statusMessage = "等待加载模型..."
static let modelFileName = "gemma-4-E4B-it"
static let modelFileExtension = "litertlm"
deinit {
cleanupSession()
if let eng = engine {
litert_lm_engine_delete(eng)
}
}
// MARK: - Session 生命周期
/// 安全清理上一个 session(在回调外部调用)
private func cleanupSession() {
sessionLock.lock()
if let sess = activeSession {
litert_lm_session_delete(sess)
activeSession = nil
log("[LLM] Session 已清理")
}
sessionLock.unlock()
}
// MARK: - 加载模型
/// 当前使用的 backend
var currentBackend = "cpu"
func loadModel(backend: String = "cpu") {
DispatchQueue.main.async {
self.statusMessage = "正在加载 E4B 模型..."
self.isLoaded = false
}
let modelPath = findModelPath()
guard let path = modelPath else {
DispatchQueue.main.async { self.statusMessage = "❌ 模型文件不存在" }
log("[LLM] 找不到模型文件")
return
}
// 如果已有引擎,先销毁
cleanupSession()
if let eng = engine {
litert_lm_engine_delete(eng)
engine = nil
}
currentBackend = backend
log("[LLM] 开始加载: \(path) (backend: \(backend))")
let startTime = CFAbsoluteTimeGetCurrent()
let cPath = strdup(path)!
let cBackend = strdup(backend)!
let settings = litert_lm_engine_settings_create(cPath, cBackend, nil, nil)
free(cPath)
free(cBackend)
guard let settings = settings else {
DispatchQueue.main.async { self.statusMessage = "❌ 创建引擎设置失败" }
return
}
litert_lm_engine_settings_set_max_num_tokens(settings, 4096)
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.path
let cCacheDir = strdup(cacheDir)!
litert_lm_engine_settings_set_cache_dir(settings, cCacheDir)
free(cCacheDir)
let eng = litert_lm_engine_create(settings)
litert_lm_engine_settings_delete(settings)
if let eng = eng {
self.engine = eng
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
DispatchQueue.main.async {
self.isLoaded = true
self.statusMessage = "模型已就绪 ✅"
}
log("[LLM] Engine 创建成功,耗时: \(String(format: "%.1f", elapsed))秒")
} else {
DispatchQueue.main.async { self.statusMessage = "❌ Engine 创建失败" }
log("[LLM] Engine 创建失败")
}
}
// MARK: - Session API 流式生成
/// 采样参数(每次 generate 前由 AgentEngine 设置)
var samplingTopK: Int32 = 64
var samplingTopP: Float = 0.95
var samplingTemperature: Float = 1.0
var maxOutputTokens: Int = 4000
func generateStream(
prompt: String,
onToken: @escaping (String) -> Void,
onComplete: @escaping (Result<String, Error>) -> 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, &params)
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<StreamContext>.fromOpaque(ctx).takeUnretainedValue()
if let err = errorMsg {
let errStr = String(cString: err)
context.finish(with: .failure(LLMError.inferenceError(errStr)))
Unmanaged<StreamContext>.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<StreamContext>.fromOpaque(ctx).release()
}
},
contextPtr
)
free(cPrompt)
if result != 0 {
Unmanaged<StreamContext>.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..<end])
}
enum LLMError: LocalizedError {
case notLoaded, sessionFailed, addChunkFailed, predictFailed
case inferenceError(String)
var errorDescription: String? {
switch self {
case .notLoaded: return "模型尚未加载"
case .sessionFailed: return "Session 创建失败"
case .addChunkFailed: return "Prompt 添加失败"
case .predictFailed: return "推理失败"
case .inferenceError(let msg): return "推理错误: \(msg)"
}
}
}
}
// MARK: - Stream Context
private class StreamContext {
var fullResponse = ""
var completed = false
let onToken: (String) -> Void
let onComplete: (Result<String, Error>) -> Void
init(onToken: @escaping (String) -> Void, onComplete: @escaping (Result<String, Error>) -> Void) {
self.onToken = onToken
self.onComplete = onComplete
}
func finish(with result: Result<String, Error>) {
guard !completed else { return }
completed = true
onComplete(result)
}
}
@@ -1,117 +0,0 @@
import Foundation
// MARK: - Prompt 构造器(Gemma 4 对话模板 + Function Calling)
//
// Gemma 4 E4B 使用新 token 格式:
// <|turn>system\n ... <turn|>
// <|turn>user\n ... <turn|>
// <|turn>model\n ... <turn|>
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<tool_call>\n{\"name\": \"load_skill\", \"arguments\": {\"skill\": \"能力名\"}}\n</tool_call>\n\n不需要能力时直接回复。用中文回答,简洁实用。"
}
}
prompt += "\n<turn|>\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)<turn|>\n"
case .assistant:
prompt += "<|turn>model\n\(msg.content)<turn|>\n"
case .system:
if let skillName = msg.skillName {
prompt += "<|turn>model\n<tool_call>\n{\"name\": \"\(skillName)\", \"arguments\": {}}\n</tool_call><turn|>\n"
}
case .skillResult:
let skillLabel = msg.skillName ?? "tool"
prompt += "<|turn>user\n工具 \(skillLabel) 的执行结果:\(msg.content)<turn|>\n"
}
}
// 当前用户消息
prompt += "<|turn>user\n\(userMessage)<turn|>\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 ["<turn|>", "<end_of_turn>", "<eos>"] {
cleanedResponse = cleanedResponse.replacingOccurrences(of: pat, with: "")
}
if isLoadSkill {
// load_skill 返回的是 Skill instructions → 让 LLM 按指令继续
return originalPrompt + cleanedResponse + """
<turn|>
<|turn>user
Skill 指令已加载:
\(skillResult)
请根据以上指令,调用对应的工具来完成用户的请求:"\(userQuestion)"
<turn|>
<|turn>model
"""
} else {
// Tool 执行结果 → 让 LLM 生成自然语言回复
return originalPrompt + cleanedResponse + """
<turn|>
<|turn>user
工具 \(skillName) 执行结果:
\(skillResult)
请根据以上结果简洁回答我的问题:"\(userQuestion)"
<turn|>
<|turn>model
"""
}
}
}
@@ -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. 根据工具返回结果,简洁回答用户
## 调用格式
<tool_call>
{"name": "工具名", "arguments": {}}
</tool_call>
"""),
("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 转换为用户友好的描述
## 调用格式
<tool_call>
{"name": "device-info", "arguments": {}}
</tool_call>
"""),
("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. 返回处理结果
## 调用格式
<tool_call>
{"name": "工具名", "arguments": {"text": "要处理的文本"}}
</tool_call>
"""),
]
}
@@ -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 = ""
}
@@ -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")
}
@@ -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
}
@@ -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<Double>,
range: ClosedRange<Double>,
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()
}
}
}
@@ -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<UUID> = []
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<UUID>
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 }
}
}
@@ -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<String> = []
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)
}
}
@@ -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)
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
-117
View File
@@ -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)
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-16
View File
@@ -1,16 +0,0 @@
{
"folders": [
{
"name": "PhoneClaw",
"path": "."
}
],
"settings": {
"git.ignoreLimitWarning": true,
"git.ignoredRepositories": [
"LiteRT-LM",
"_mlx-swift-lm",
"_mediapipe-samples"
]
}
}
-30
View File
@@ -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