refactor: update agent service (context, run, sse)
This commit is contained in:
parent
4d2e4d8b36
commit
3e07fedd0c
@ -10,6 +10,7 @@ use ai::{
|
||||
},
|
||||
};
|
||||
use db::sqlx;
|
||||
use model::repos::RepoModel;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::SessionContext;
|
||||
@ -69,6 +70,19 @@ impl AppService {
|
||||
memories_text,
|
||||
));
|
||||
}
|
||||
|
||||
// Inject repo context from @[repo:...] mentions in the input.
|
||||
if let Some(workspace_id) = ctx.workspace_id {
|
||||
let repo_context = self
|
||||
.agent_resolve_mentioned_repos(workspace_id, &input)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "failed to resolve mentioned repos");
|
||||
Vec::new()
|
||||
});
|
||||
all_context.extend(repo_context);
|
||||
}
|
||||
|
||||
if !all_context.is_empty() {
|
||||
request = request.with_context(all_context);
|
||||
}
|
||||
@ -226,6 +240,72 @@ impl AppService {
|
||||
|
||||
Ok(all_hits)
|
||||
}
|
||||
/// Parse `@[repo:name:label]` mentions from the input and resolve them to
|
||||
/// AgentContextChunks with repo metadata from the database.
|
||||
pub(crate) async fn agent_resolve_mentioned_repos(
|
||||
&self,
|
||||
workspace_id: Uuid,
|
||||
input: &str,
|
||||
) -> Result<Vec<AgentContextChunk>, AppError> {
|
||||
let repo_names = extract_repo_mentions(input);
|
||||
if repo_names.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut chunks = Vec::with_capacity(repo_names.len());
|
||||
for name in &repo_names {
|
||||
match self.resolve_repo_by_name(workspace_id, name).await {
|
||||
Ok(Some(repo)) => {
|
||||
let content = format_repo_context(&repo);
|
||||
chunks.push(AgentContextChunk::new(
|
||||
format!("repo:{}", repo.name),
|
||||
content,
|
||||
));
|
||||
tracing::info!(
|
||||
repo = %repo.name,
|
||||
"injected repo context from @mention"
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
repo_name = %name,
|
||||
"mentioned repo not found, skipping"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
repo_name = %name,
|
||||
error = %e,
|
||||
"failed to look up mentioned repo"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
/// Look up a single repo by workspace + name.
|
||||
async fn resolve_repo_by_name(
|
||||
&self,
|
||||
workspace_id: Uuid,
|
||||
name: &str,
|
||||
) -> Result<Option<RepoModel>, AppError> {
|
||||
let repo = sqlx::query_as::<_, RepoModel>(
|
||||
"SELECT id, wk, name, description, default_branch, visibility, \
|
||||
size_bytes, is_archived, is_template, is_mirror, created_by, \
|
||||
storage_path, created_at, updated_at, deleted_at \
|
||||
FROM repo WHERE wk = $1 AND name = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(name)
|
||||
.fetch_optional(self.db.reader())
|
||||
.await
|
||||
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn agent_upsert_knowledge(
|
||||
&self,
|
||||
@ -264,3 +344,115 @@ impl AppService {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo mention helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract unique repo names from `@[repo:name:label]` mentions in the input.
|
||||
fn extract_repo_mentions(input: &str) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// Simple manual parser for @[repo:name:label]
|
||||
let bytes = input.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
// Look for "@[repo:"
|
||||
if i + 7 < len
|
||||
&& bytes[i] == b'@'
|
||||
&& bytes[i + 1] == b'['
|
||||
&& bytes[i + 2] == b'r'
|
||||
&& bytes[i + 3] == b'e'
|
||||
&& bytes[i + 4] == b'p'
|
||||
&& bytes[i + 5] == b'o'
|
||||
&& bytes[i + 6] == b':'
|
||||
{
|
||||
let start = i + 7; // after "@[repo:"
|
||||
// Find the closing ']' — but skip ':' and the label part.
|
||||
// Format is @[repo:name:label], we want "name".
|
||||
if let Some(name_end) = input[start..].find(':') {
|
||||
let name = &input[start..start + name_end];
|
||||
if !name.is_empty() && seen.insert(name.to_string()) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
// Skip past the closing ']'
|
||||
if let Some(closing) = input[start + name_end..].find(']') {
|
||||
i = start + name_end + closing + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
/// Format a RepoModel into a concise context string for the AI.
|
||||
fn format_repo_context(repo: &RepoModel) -> String {
|
||||
let mut s = format!(
|
||||
"Repository: {} (id: {})\n",
|
||||
repo.name, repo.id
|
||||
);
|
||||
if let Some(ref desc) = repo.description {
|
||||
if !desc.trim().is_empty() {
|
||||
s.push_str(&format!("Description: {}\n", desc.trim()));
|
||||
}
|
||||
}
|
||||
s.push_str(&format!("Default branch: {}\n", repo.default_branch));
|
||||
s.push_str(&format!("Visibility: {}\n", repo.visibility));
|
||||
if repo.is_archived {
|
||||
s.push_str("Status: archived\n");
|
||||
}
|
||||
if repo.is_template {
|
||||
s.push_str("Kind: template repository\n");
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"Created: {}\n",
|
||||
repo.created_at.format("%Y-%m-%d")
|
||||
));
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_mentions_single() {
|
||||
let input = "@[repo:my-repo:my-repo] what's the latest commit?";
|
||||
let names = extract_repo_mentions(input);
|
||||
assert_eq!(names, vec!["my-repo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_mentions_multiple() {
|
||||
let input = "compare @[repo:backend:backend] with @[repo:frontend:frontend]";
|
||||
let names = extract_repo_mentions(input);
|
||||
assert_eq!(names, vec!["backend", "frontend"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_mentions_dedupe() {
|
||||
let input = "look at @[repo:a:a] and also @[repo:a:a] please";
|
||||
let names = extract_repo_mentions(input);
|
||||
assert_eq!(names, vec!["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_mentions_none() {
|
||||
let input = "hello world no mentions here";
|
||||
let names = extract_repo_mentions(input);
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_ignores_other_mention_types() {
|
||||
let input = "@[user:abc:John] and @[repo:myrepo:myrepo]";
|
||||
let names = extract_repo_mentions(input);
|
||||
assert_eq!(names, vec!["myrepo"]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,16 +183,21 @@ impl AppService {
|
||||
.pending_title
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.or_else(|| {
|
||||
let first_line =
|
||||
req.input.lines().next().unwrap_or(&req.input);
|
||||
let truncated: String =
|
||||
first_line.chars().take(50).collect();
|
||||
if truncated.trim().is_empty() {
|
||||
None
|
||||
} else if first_line.len() > 50 {
|
||||
Some(format!("{}…", truncated.trim_end()))
|
||||
// Only auto-set title from input when still default.
|
||||
if conversation.title == "New Chat" || conversation.title.trim().is_empty() {
|
||||
let first_line =
|
||||
req.input.lines().next().unwrap_or(&req.input);
|
||||
let truncated: String =
|
||||
first_line.chars().take(50).collect();
|
||||
if truncated.trim().is_empty() {
|
||||
None
|
||||
} else if first_line.len() > 50 {
|
||||
Some(format!("{}…", truncated.trim_end()))
|
||||
} else {
|
||||
Some(truncated.trim().to_string())
|
||||
}
|
||||
} else {
|
||||
Some(truncated.trim().to_string())
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -213,10 +213,15 @@ impl AppService {
|
||||
let title = agent_ctx.pending_title
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.or_else(|| {
|
||||
let first_line = first_input.lines().next().unwrap_or(&first_input);
|
||||
let truncated: String = first_line.chars().take(50).collect();
|
||||
if truncated.trim().is_empty() { None }
|
||||
else { Some(if first_line.len() > 50 { format!("{}…", truncated.trim_end()) } else { truncated.trim().to_string() }) }
|
||||
// Only auto-set title from input when still default.
|
||||
if conversation.title == "New Chat" || conversation.title.trim().is_empty() {
|
||||
let first_line = first_input.lines().next().unwrap_or(&first_input);
|
||||
let truncated: String = first_line.chars().take(50).collect();
|
||||
if truncated.trim().is_empty() { None }
|
||||
else { Some(if first_line.len() > 50 { format!("{}…", truncated.trim_end()) } else { truncated.trim().to_string() }) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(new_title) = &title {
|
||||
if self_clone.update_conversation_title(conversation_id, new_title).await.is_ok() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user