feat: add workshop-llm-detector

Signed-off-by: Catty Steve <4795515+Catty2014@user.noreply.gitee.com>
This commit is contained in:
Catty Steve
2025-11-09 17:27:18 +08:00
parent 344dc10b98
commit 004df79e66
23 changed files with 2566 additions and 21 deletions
+1
View File
@@ -1 +1,2 @@
target
playground
+3
View File
@@ -0,0 +1,3 @@
[submodule "rust-tongsuo"]
path = rust-tongsuo
url = https://github.com/Tongsuo-Project/rust-tongsuo.git
Submodule
+1
Submodule rust-tongsuo added at 6248690a45
-1
View File
@@ -1 +0,0 @@
/target
-7
View File
@@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "workshop-config-manager"
version = "0.1.0"
-6
View File
@@ -1,6 +0,0 @@
[package]
name = "workshop-config-manager"
version = "0.1.0"
edition = "2024"
[dependencies]
-3
View File
@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}
+1 -1
View File
@@ -574,7 +574,7 @@ dependencies = [
]
[[package]]
name = "workshop-token"
name = "workshop-deviceid"
version = "0.1.0"
dependencies = [
"chrono",
@@ -1,5 +1,5 @@
extern crate workshop_token;
use workshop_token::DeviceIdGenerator;
extern crate workshop_deviceid;
use workshop_deviceid::DeviceIdGenerator;
// server/src/auth/device_id_example.rs
+1 -1
View File
@@ -1,4 +1,4 @@
// server/src/auth/device_id_generator.rs
// device_id_generator.rs
use serde::Serialize;
mod device_info;
use crate::device_info::{DeviceInfo, DeviceInfoCollector};
+1
View File
@@ -0,0 +1 @@
target
+2045
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "workshop-llm-detector"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "workshop-llm-detector"
path = "src/main.rs"
[lib]
name = "workshop_llm_detector"
path = "src/lib.rs"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
clap = { version = "4.5.51", features = ["derive"] }
log = "0.4.28"
ollama-rs = "0.3.2"
openai_api_rust = "0.1.9"
reqwest = { version = "0.12.24", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
@@ -0,0 +1,29 @@
name: "Rust"
project_types:
- "rust"
- "cargo"
install_path: "/install"
artifacts:
- "/install/bin/*"
- "/install/lib/*.so"
- "/install/lib/*.a"
- "/target/release/*"
dependencies:
deb:
- "build-essential"
- "curl"
- "pkg-config"
- "libssl-dev"
rpm:
- "gcc"
- "gcc-c++"
- "curl"
- "openssl-devel"
pacman:
- "base-devel"
- "curl"
- "openssl"
environment:
RUSTUP_DIST_SERVER: "https://static.rust-lang.org"
CARGO_HOME: "/usr/local/cargo"
RUSTUP_HOME: "/usr/local/rustup"
@@ -0,0 +1,24 @@
name: "make"
project_types:
- "make"
- "makefile"
install_path: "/install"
artifacts:
- "/install/*"
dependencies:
deb:
- "build-essential"
- "curl"
- "pkg-config"
- "libssl-dev"
rpm:
- "gcc"
- "gcc-c++"
- "curl"
- "openssl-devel"
pacman:
- "base-devel"
- "curl"
- "openssl"
environment:
NONE: "none"
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookbook {
pub name: String,
pub project_types: Vec<String>,
pub install_path: String,
pub artifacts: Vec<String>,
pub dependencies: HashMap<String, Vec<String>>, // key: package_type (deb, rpm, etc.)
pub environment: HashMap<String, String>,
}
#[derive(Debug, thiserror::Error)]
pub enum CookbookError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("Cookbook not found for project type: {0}")]
NotFound(String),
}
pub struct CookbookManager {
cookbooks: HashMap<String, Cookbook>,
}
impl CookbookManager {
pub fn load_from_dir<P: AsRef<Path>>(dir: P) -> Result<Self, CookbookError> {
let mut cookbooks = HashMap::new();
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "yaml" || ext == "yml") {
if let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) {
if file_name.ends_with(".cookbook") {
let content = fs::read_to_string(&path)?;
let cookbook: Cookbook = serde_yaml::from_str(&content)?;
// Register all project types for this cookbook
for project_type in &cookbook.project_types {
cookbooks.insert(project_type.clone(), cookbook.clone());
}
}
}
}
}
Ok(CookbookManager { cookbooks })
}
pub fn get_cookbook(&self, project_type: &str) -> Result<&Cookbook, CookbookError> {
self.cookbooks
.get(project_type)
.ok_or_else(|| CookbookError::NotFound(project_type.to_string()))
}
}
+175
View File
@@ -0,0 +1,175 @@
mod cookbook;
mod llm;
mod llm_output;
pub use cookbook::{Cookbook, CookbookManager, CookbookError};
pub use llm::{LLMClient, ollama::OllamaClient, openai::OpenAIClient};
pub use llm_output::LLMOutput;
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use anyhow::Result;
use serde_yaml;
pub struct CICDLLMHelper {
cookbook_manager: CookbookManager,
}
impl CICDLLMHelper {
pub fn new(cookbook_dir: &str) -> Result<Self, CookbookError> {
let cookbook_manager = CookbookManager::load_from_dir(cookbook_dir)?;
Ok(Self { cookbook_manager })
}
pub async fn analyze_repository(
&self,
repository_path: &str,
additional_files: &[String],
llm_client: &dyn LLMClient,
package_type: &str,
) -> Result<AnalysisResult> {
// Read README.md
let readme_path = Path::new(repository_path).join("README.md");
let readme_content = fs::read_to_string(&readme_path)
.unwrap_or_else(|_| "No README.md found".to_string());
// Read additional files
let mut file_contents = Vec::new();
for file in additional_files {
let file_path = Path::new(repository_path).join(file);
if let Ok(content) = fs::read_to_string(&file_path) {
file_contents.push(format!("File: {}\n{}\n", file, content));
}
}
// Prepare prompt for LLM
let prompt = self.build_prompt(&readme_content, &file_contents, package_type);
// Get LLM response
let llm_response = llm_client.generate(&prompt).await?;
println!("LLM Response: {}", llm_response);
// Parse LLM output
let llm_output: LLMOutput = serde_yaml::from_str(&llm_response)
.map_err(|e| anyhow::anyhow!("Failed to parse LLM output: {}", e))?;
// Get cookbook for project type
let cookbook = self.cookbook_manager.get_cookbook(&llm_output.project_type)?;
// Merge cookbook and LLM output
let result = self.merge_outputs(&llm_output, cookbook, package_type);
Ok(result)
}
fn build_prompt(&self, readme_content: &str, file_contents: &[String], package_type: &str) -> String {
format!(
r#"Analyze the following repository files and determine the build configuration.
README.md:
{}
Additional files:
{}
Please provide the analysis in YAML format with the following structure:
project_type: <string>
dependencies: <list of package names for {package_type} packages>
build_steps: <list of shell commands>
artifacts: <list of artifact paths, use /install as base directory>
environment: <key-value pairs for environment variables>
other: <any other relevant information>
Focus on identifying:
1. The project type (e.g., rust, python, nodejs, cmake, make, etc.)
2. Dependencies needed for building (as {package_type} packages)
3. Build steps as shell commands
4. Artifact paths (output files, typically in /install)
5. Environment variables needed for building
Output only valid YAML, no additional text and remove '```' in output(output as plain text)."#,
readme_content,
file_contents.join("\n"),
package_type = package_type
)
}
fn merge_outputs(&self, llm_output: &LLMOutput, cookbook: &Cookbook, package_type: &str) -> AnalysisResult {
// Merge dependencies
let cookbook_deps = cookbook.dependencies.get(package_type)
.cloned()
.unwrap_or_default();
let mut all_dependencies = cookbook_deps;
all_dependencies.extend_from_slice(&llm_output.dependencies);
all_dependencies.sort();
all_dependencies.dedup();
// Merge environment variables
let mut all_environment = cookbook.environment.clone();
for (key, value) in &llm_output.environment {
all_environment.insert(key.clone(), value.clone());
}
// Merge artifacts
let mut all_artifacts = cookbook.artifacts.clone();
all_artifacts.extend_from_slice(&llm_output.artifacts);
all_artifacts.sort();
all_artifacts.dedup();
AnalysisResult {
dependencies: all_dependencies,
build_steps: llm_output.build_steps.clone(),
artifacts: all_artifacts,
environment: all_environment,
project_type: llm_output.project_type.clone(),
other: llm_output.other.clone(),
}
}
pub fn write_output_files(&self, result: &AnalysisResult, output_dir: &str) -> Result<()> {
let output_path = Path::new(output_dir);
// Write prebake.tmpl.yaml
let prebake_content = serde_yaml::to_string(&PrebakeTemplate {
dependencies: &result.dependencies,
environment: &result.environment,
})?;
fs::write(output_path.join("prebake.tmpl.yaml"), prebake_content)?;
// Write finalize.tmpl.yaml
let finalize_content = serde_yaml::to_string(&FinalizeTemplate {
artifacts: &result.artifacts,
})?;
fs::write(output_path.join("finalize.tmpl.yaml"), finalize_content)?;
// Write build.sh
let build_script = result.build_steps.join("\n");
fs::write(output_path.join("build.sh"), build_script)?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AnalysisResult {
pub dependencies: Vec<String>,
pub build_steps: Vec<String>,
pub artifacts: Vec<String>,
pub environment: HashMap<String, String>,
pub project_type: String,
pub other: HashMap<String, serde_yaml::Value>,
}
#[derive(Serialize)]
struct PrebakeTemplate<'a> {
dependencies: &'a Vec<String>,
environment: &'a HashMap<String, String>,
}
#[derive(Serialize)]
struct FinalizeTemplate<'a> {
artifacts: &'a Vec<String>,
}
+7
View File
@@ -0,0 +1,7 @@
use async_trait::async_trait;
use anyhow::Result;
#[async_trait]
pub trait LLMClient {
async fn generate(&self, prompt: &str) -> Result<String>;
}
+6
View File
@@ -0,0 +1,6 @@
mod client;
pub use client::LLMClient;
pub mod ollama;
pub mod openai;
+27
View File
@@ -0,0 +1,27 @@
use async_trait::async_trait;
use anyhow::Result;
use ollama_rs::generation::completion::request::GenerationRequest;
use ollama_rs::Ollama;
use super::LLMClient;
pub struct OllamaClient {
client: Ollama,
model: String,
}
impl OllamaClient {
pub fn new(url: String, model: String) -> Self {
let client = Ollama::new(url, 11434);
Self { client, model }
}
}
#[async_trait]
impl LLMClient for OllamaClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let request = GenerationRequest::new(self.model.clone(), prompt.to_string());
let response = self.client.generate(request).await?;
Ok(response.response)
}
}
+56
View File
@@ -0,0 +1,56 @@
use async_trait::async_trait;
use anyhow::Result;
use openai_api_rust::*;
use openai_api_rust::chat::*;
use super::LLMClient;
pub struct OpenAIClient {
client: OpenAI,
model: String,
}
impl OpenAIClient {
pub fn new(api_key: String, base_url: Option<String>, model: String) -> Self {
let auth = Auth::new(api_key.as_str());
let client = if let Some(url) = base_url {
OpenAI::new(auth, url.as_str())
} else {
OpenAI::new(auth, "https://api.openai.com/v1/")
};
Self { client, model }
}
}
#[async_trait]
impl LLMClient for OpenAIClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let message = Message {
role: Role::User,
content: prompt.to_string(),
};
let body = ChatBody {
model: self.model.clone(),
messages: vec![message],
max_tokens: Some(2048),
temperature: Some(0.1),
top_p: Some(0.1),
n: Some(1),
stream: Some(false),
stop: None,
presence_penalty: None,
frequency_penalty: None,
logit_bias: None,
user: None,
};
let response = self.client.chat_completion_create(&body).unwrap();
if let Some(choice) = response.choices.first() {
Ok(choice.message.as_ref().unwrap().content.clone())
} else {
anyhow::bail!("No response from OpenAI")
}
}
}
+13
View File
@@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMOutput {
pub project_type: String,
pub dependencies: Vec<String>,
pub build_steps: Vec<String>,
pub artifacts: Vec<String>,
pub environment: HashMap<String, String>,
#[serde(flatten)]
pub other: HashMap<String, serde_yaml::Value>,
}
+87
View File
@@ -0,0 +1,87 @@
use clap::Parser;
use log::error;
use workshop_llm_detector::{CICDLLMHelper, LLMClient, OllamaClient, OpenAIClient};
// use std::path::Path;
use anyhow::Result;
#[derive(Parser)]
#[command(name = "workshop-llm-detector")]
#[command(about = "LLM helper for analyzing repositories")]
struct Cli {
/// Repository path to analyze
repository_path: String,
/// Additional files to read (comma separated)
#[arg(short, long, value_delimiter = ',')]
files: Vec<String>,
/// LLM Provider
#[arg(long, default_value = "openai")]
provider: String,
/// Model to use
#[arg(long)]
model: String,
/// Model API URL
#[arg(long)]
model_url: Option<String>,
/// Package type for dependencies
#[arg(long, default_value = "deb")]
package_type: String,
/// Cookbook directory
#[arg(long, default_value = "cookbook")]
cookbook_dir: String,
/// Output directory
#[arg(short, long, default_value = ".")]
output_dir: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Cli::parse();
// Initialize cookbook manager
let helper = CICDLLMHelper::new(&args.cookbook_dir)?;
// Initialize LLM client
let llm_client: Box<dyn LLMClient> = match args.provider.as_str() {
"openai" => {
let api_key = std::env::var("OPENAI_API_KEY")
.expect("OPENAI_API_KEY environment variable must be set");
let base_url = args.model_url.clone();
Box::new(OpenAIClient::new(api_key, base_url, args.model))
}
"ollama" => {
let url = args.model_url.unwrap_or_else(|| "http://localhost:11434".to_string());
Box::new(OllamaClient::new(url, args.model))
}
_ => {
error!("Unsupported provider: {}", args.provider);
std::process::exit(1);
}
};
// Analyze repository
let result = helper.analyze_repository(
&args.repository_path,
&args.files,
&*llm_client,
&args.package_type,
).await?;
println!("Analysis completed for project type: {}", result.project_type);
println!("Found {} dependencies", result.dependencies.len());
println!("Generated {} build steps", result.build_steps.len());
println!("Identified {} artifacts", result.artifacts.len());
// Write output files
helper.write_output_files(&result, &args.output_dir)?;
println!("Output files written to {}", args.output_dir);
Ok(())
}