feat: template examples + recent templates quick buttons
- Add TemplateExample struct and examples scanning (local dir + git repo) - Exclude examples/ from copy_dir_recursive - Frontend: recent templates (localStorage), template-specific example buttons
This commit is contained in:
parent
feb2a08d97
commit
815477a73b
@ -22,11 +22,18 @@ pub struct LoadedTemplate {
|
|||||||
pub kb_files: Vec<(String, String)>,
|
pub kb_files: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct TemplateExample {
|
||||||
|
pub label: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct TemplateListItem {
|
pub struct TemplateListItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub examples: Vec<TemplateExample>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Template directories ---
|
// --- Template directories ---
|
||||||
@ -54,6 +61,78 @@ pub fn templates_dir() -> &'static str {
|
|||||||
|
|
||||||
// --- Scanning ---
|
// --- Scanning ---
|
||||||
|
|
||||||
|
/// Scan a local examples/ directory for .md files.
|
||||||
|
async fn scan_examples_dir(dir: &Path) -> Vec<TemplateExample> {
|
||||||
|
let mut examples = Vec::new();
|
||||||
|
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return examples,
|
||||||
|
};
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("example")
|
||||||
|
.to_string();
|
||||||
|
if let Ok(text) = tokio::fs::read_to_string(&path).await {
|
||||||
|
examples.push(TemplateExample { label, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
examples.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
examples
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan examples from a git repo ref via `git ls-tree` + `git show`.
|
||||||
|
async fn scan_examples_git(repo: &Path, ref_name: &str, template_path: &str) -> Vec<TemplateExample> {
|
||||||
|
let examples_prefix = if template_path.is_empty() {
|
||||||
|
"examples/".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/examples/", template_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
let tree_output = match tokio::process::Command::new("git")
|
||||||
|
.args(["ls-tree", "--name-only", ref_name, &examples_prefix])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut examples = Vec::new();
|
||||||
|
for file_path in tree_output.lines() {
|
||||||
|
let file_path = file_path.trim();
|
||||||
|
if !file_path.ends_with(".md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = Path::new(file_path)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("example")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let show_ref = format!("{}:{}", ref_name, file_path);
|
||||||
|
if let Ok(o) = tokio::process::Command::new("git")
|
||||||
|
.args(["show", &show_ref])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if o.status.success() {
|
||||||
|
let text = String::from_utf8_lossy(&o.stdout).to_string();
|
||||||
|
examples.push(TemplateExample { label, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
examples.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
examples
|
||||||
|
}
|
||||||
|
|
||||||
/// List all templates from both built-in and repo (all branches).
|
/// List all templates from both built-in and repo (all branches).
|
||||||
pub async fn list_all_templates() -> Vec<TemplateListItem> {
|
pub async fn list_all_templates() -> Vec<TemplateListItem> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@ -73,10 +152,12 @@ pub async fn list_all_templates() -> Vec<TemplateListItem> {
|
|||||||
let meta_path = entry.path().join("template.json");
|
let meta_path = entry.path().join("template.json");
|
||||||
if let Ok(data) = tokio::fs::read_to_string(&meta_path).await {
|
if let Ok(data) = tokio::fs::read_to_string(&meta_path).await {
|
||||||
if let Ok(info) = serde_json::from_str::<TemplateInfo>(&data) {
|
if let Ok(info) = serde_json::from_str::<TemplateInfo>(&data) {
|
||||||
|
let examples = scan_examples_dir(&entry.path().join("examples")).await;
|
||||||
items.push(TemplateListItem {
|
items.push(TemplateListItem {
|
||||||
id,
|
id,
|
||||||
name: info.name,
|
name: info.name,
|
||||||
description: info.description,
|
description: info.description,
|
||||||
|
examples,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -159,11 +240,13 @@ async fn scan_repo_all_branches(repo: &Path) -> Vec<TemplateListItem> {
|
|||||||
|
|
||||||
// Try to read template.json via git show
|
// Try to read template.json via git show
|
||||||
let (name, description) = read_git_file_json(repo, line, template_path).await;
|
let (name, description) = read_git_file_json(repo, line, template_path).await;
|
||||||
|
let examples = scan_examples_git(repo, line, template_path).await;
|
||||||
|
|
||||||
items.push(TemplateListItem {
|
items.push(TemplateListItem {
|
||||||
id: template_id.clone(),
|
id: template_id.clone(),
|
||||||
name: name.unwrap_or_else(|| template_id.clone()),
|
name: name.unwrap_or_else(|| template_id.clone()),
|
||||||
description: description.unwrap_or_default(),
|
description: description.unwrap_or_default(),
|
||||||
|
examples,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -379,6 +462,7 @@ async fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
|
|||||||
&& (name_str == "template.json"
|
&& (name_str == "template.json"
|
||||||
|| name_str == "tools"
|
|| name_str == "tools"
|
||||||
|| name_str == "kb"
|
|| name_str == "kb"
|
||||||
|
|| name_str == "examples"
|
||||||
|| name_str == "INSTRUCTIONS.md")
|
|| name_str == "INSTRUCTIONS.md")
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
listTemplates: () =>
|
listTemplates: () =>
|
||||||
request<{ id: string; name: string; description: string }[]>('/templates'),
|
request<{ id: string; name: string; description: string; examples: { label: string; text: string }[] }[]>('/templates'),
|
||||||
|
|
||||||
listSteps: (workflowId: string) =>
|
listSteps: (workflowId: string) =>
|
||||||
request<ExecutionLogEntry[]>(`/workflows/${workflowId}/steps`),
|
request<ExecutionLogEntry[]>(`/workflows/${workflowId}/steps`),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import examples from '../examples.json'
|
import examples from '../examples.json'
|
||||||
|
|
||||||
@ -8,13 +8,40 @@ const emit = defineEmits<{
|
|||||||
cancel: []
|
cancel: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const RECENT_KEY = 'tori-recent-templates'
|
||||||
|
const MAX_RECENT = 3
|
||||||
|
|
||||||
const requirement = ref('')
|
const requirement = ref('')
|
||||||
const inputEl = ref<HTMLTextAreaElement>()
|
const inputEl = ref<HTMLTextAreaElement>()
|
||||||
const templates = ref<{ id: string; name: string; description: string }[]>([])
|
const templates = ref<{ id: string; name: string; description: string; examples: { label: string; text: string }[] }[]>([])
|
||||||
const selectedTemplate = ref('')
|
const selectedTemplate = ref('')
|
||||||
|
const recentTemplates = ref<{ id: string; name: string }[]>([])
|
||||||
|
|
||||||
|
// Load recent templates from localStorage
|
||||||
|
function loadRecent(): { id: string; name: string }[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecent(id: string, name: string) {
|
||||||
|
const list = loadRecent().filter(t => t.id !== id)
|
||||||
|
list.unshift({ id, name })
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, MAX_RECENT)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template-specific examples based on current selection
|
||||||
|
const templateExamples = computed(() => {
|
||||||
|
if (!selectedTemplate.value) return []
|
||||||
|
const t = templates.value.find(t => t.id === selectedTemplate.value)
|
||||||
|
return t?.examples ?? []
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
inputEl.value?.focus()
|
inputEl.value?.focus()
|
||||||
|
recentTemplates.value = loadRecent()
|
||||||
try {
|
try {
|
||||||
templates.value = await api.listTemplates()
|
templates.value = await api.listTemplates()
|
||||||
} catch {
|
} catch {
|
||||||
@ -22,9 +49,19 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function selectRecentTemplate(id: string) {
|
||||||
|
selectedTemplate.value = selectedTemplate.value === id ? '' : id
|
||||||
|
}
|
||||||
|
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
const text = requirement.value.trim()
|
const text = requirement.value.trim()
|
||||||
if (text) emit('submit', text, selectedTemplate.value || undefined)
|
if (!text) return
|
||||||
|
const tplId = selectedTemplate.value || undefined
|
||||||
|
if (tplId) {
|
||||||
|
const t = templates.value.find(t => t.id === tplId)
|
||||||
|
if (t) saveRecent(t.id, t.name)
|
||||||
|
}
|
||||||
|
emit('submit', text, tplId)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -39,6 +76,32 @@ function onSubmit() {
|
|||||||
@click="requirement = Array.isArray(ex.text) ? ex.text.join('\n') : ex.text"
|
@click="requirement = Array.isArray(ex.text) ? ex.text.join('\n') : ex.text"
|
||||||
>{{ ex.label }}</span>
|
>{{ ex.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="templateExamples.length" class="create-examples">
|
||||||
|
<span class="example-section-label">模板示例</span>
|
||||||
|
<span
|
||||||
|
v-for="ex in templateExamples"
|
||||||
|
:key="ex.label"
|
||||||
|
class="example-tag template-example"
|
||||||
|
@click="requirement = ex.text"
|
||||||
|
>{{ ex.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="recentTemplates.length" class="recent-templates">
|
||||||
|
<span class="example-section-label">最近</span>
|
||||||
|
<span
|
||||||
|
v-for="rt in recentTemplates"
|
||||||
|
:key="rt.id"
|
||||||
|
class="example-tag"
|
||||||
|
:class="{ active: selectedTemplate === rt.id }"
|
||||||
|
@click="selectRecentTemplate(rt.id)"
|
||||||
|
>{{ rt.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="templates.length" class="template-select">
|
||||||
|
<label>模板</label>
|
||||||
|
<select v-model="selectedTemplate">
|
||||||
|
<option value="">自动选择</option>
|
||||||
|
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="requirement"
|
v-model="requirement"
|
||||||
@ -48,13 +111,6 @@ function onSubmit() {
|
|||||||
@keydown.ctrl.enter="onSubmit"
|
@keydown.ctrl.enter="onSubmit"
|
||||||
@keydown.meta.enter="onSubmit"
|
@keydown.meta.enter="onSubmit"
|
||||||
/>
|
/>
|
||||||
<div v-if="templates.length" class="template-select">
|
|
||||||
<label>模板</label>
|
|
||||||
<select v-model="selectedTemplate">
|
|
||||||
<option value="">自动选择</option>
|
|
||||||
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="create-hint">Ctrl+Enter 提交</div>
|
<div class="create-hint">Ctrl+Enter 提交</div>
|
||||||
<div class="create-actions">
|
<div class="create-actions">
|
||||||
<button class="btn-cancel" @click="emit('cancel')">取消</button>
|
<button class="btn-cancel" @click="emit('cancel')">取消</button>
|
||||||
@ -81,6 +137,13 @@ function onSubmit() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.example-tag {
|
.example-tag {
|
||||||
@ -100,6 +163,23 @@ function onSubmit() {
|
|||||||
background: rgba(79, 195, 247, 0.08);
|
background: rgba(79, 195, 247, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.example-tag.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(79, 195, 247, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-tag.template-example {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-templates {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.create-textarea {
|
.create-textarea {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user