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:
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.
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Submodule
+1
Submodule LiteRT-LM added at 24a8bb189f.
File renamed without changes.
@@ -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;
|
||||
|
||||
@@ -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 "推理失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//
|
||||
// PhoneClaw-Bridging-Header.h
|
||||
// 直接调用 LiteRT-LM C API
|
||||
//
|
||||
|
||||
#include "llm_inference_engine.h"
|
||||
@@ -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_
|
||||
@@ -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 *
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
log("[LLM] Session config: topK=\(params.top_k) topP=\(params.top_p) temp=\(params.temperature) maxTokens=\(self.maxOutputTokens)")
|
||||
}
|
||||
|
||||
// 创建新 session(传入 config)
|
||||
let session = litert_lm_engine_create_session(engine, sessionConfig)
|
||||
if let cfg = sessionConfig { litert_lm_session_config_delete(cfg) }
|
||||
guard let session = session else {
|
||||
DispatchQueue.main.async {
|
||||
self.isGenerating = false
|
||||
onComplete(.failure(LLMError.sessionFailed))
|
||||
}
|
||||
log("[LLM] Session 创建失败")
|
||||
return
|
||||
}
|
||||
|
||||
self.sessionLock.lock()
|
||||
self.activeSession = session
|
||||
self.sessionLock.unlock()
|
||||
log("[LLM] Session 创建成功")
|
||||
|
||||
// 构建 InputData(原始文本)
|
||||
let cPrompt = strdup(prompt)!
|
||||
var input = InputData()
|
||||
input.type = kInputText
|
||||
input.data = UnsafeRawPointer(cPrompt)
|
||||
input.size = strlen(cPrompt)
|
||||
|
||||
// 创建流式回调上下文
|
||||
let context = StreamContext(onToken: onToken, onComplete: { [weak self] result in
|
||||
DispatchQueue.main.async {
|
||||
self?.isGenerating = false
|
||||
onComplete(result)
|
||||
}
|
||||
// ★ 不在回调中删除 session,由下次 generateStream 清理
|
||||
})
|
||||
let contextPtr = Unmanaged.passRetained(context).toOpaque()
|
||||
|
||||
let result = litert_lm_session_generate_content_stream(
|
||||
session,
|
||||
&input,
|
||||
1,
|
||||
{ callbackData, chunk, isFinal, errorMsg in
|
||||
guard let ctx = callbackData else { return }
|
||||
let context = Unmanaged<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.
@@ -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.
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "PhoneClaw",
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"git.ignoreLimitWarning": true,
|
||||
"git.ignoredRepositories": [
|
||||
"LiteRT-LM",
|
||||
"_mlx-swift-lm",
|
||||
"_mediapipe-samples"
|
||||
]
|
||||
}
|
||||
}
|
||||
-30
@@ -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
|
||||
Reference in new issue
Block a user