feat(vue): accumulated parity work — Q&A, Apply highlights, AppNav switcher, cloud API
API additions (dev-api.py split across this and next commit):
- /api/jobs/{job_id}/qa GET/PATCH/suggest — Interview Prep answer storage + LLM suggestions
- /api/settings/ui-preference POST — persist streamlit/vue preference to user.yaml
- cancel_task() added to scripts/db.py (per-task cancel for Danger Zone)
Vue / UI:
- AppNav: "⚡ Classic" button to switch back to Streamlit UI (writes cookie + persists to user.yaml)
- ApplyWorkspace: Resume Highlights panel (collapsible skills/domains/keywords with job-match highlighting)
- SettingsView: hide Data tab in cloud mode (showData guard)
- ResumeProfileView: minor improvements
- useApi.ts: error handling improvements
Infra:
- compose.cloud.yml: add api service (uvicorn dev_api running in cloud container)
- docker/web/nginx.conf: proxy /api/* to api service in cloud mode
- README.md: Vue SPA now listed as Free tier feature
This commit is contained in:
parent
173da49087
commit
53b07568d9
10 changed files with 527 additions and 10 deletions
|
|
@ -154,7 +154,7 @@ Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
|
||||||
| Calendar sync (Google, Apple) | Paid |
|
| Calendar sync (Google, Apple) | Paid |
|
||||||
| Slack notifications | Paid |
|
| Slack notifications | Paid |
|
||||||
| CircuitForge shared cover-letter model | Paid |
|
| CircuitForge shared cover-letter model | Paid |
|
||||||
| Vue 3 SPA beta UI | Paid |
|
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free |
|
||||||
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
|
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
|
||||||
| Cover letter model fine-tuning (your writing, your model) | Premium |
|
| Cover letter model fine-tuning (your writing, your model) | Premium |
|
||||||
| Multi-user support | Premium |
|
| Multi-user support | Premium |
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,30 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: peregrine/Dockerfile.cfcore
|
||||||
|
command: >
|
||||||
|
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
|
||||||
|
volumes:
|
||||||
|
- /devl/menagerie-data:/devl/menagerie-data
|
||||||
|
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
|
||||||
|
environment:
|
||||||
|
- CLOUD_MODE=true
|
||||||
|
- CLOUD_DATA_ROOT=/devl/menagerie-data
|
||||||
|
- STAGING_DB=/devl/menagerie-data/cloud-default.db
|
||||||
|
- DIRECTUS_JWT_SECRET=${DIRECTUS_JWT_SECRET}
|
||||||
|
- CF_SERVER_SECRET=${CF_SERVER_SECRET}
|
||||||
|
- PLATFORM_DB_URL=${PLATFORM_DB_URL}
|
||||||
|
- HEIMDALL_URL=${HEIMDALL_URL:-http://cf-license:8000}
|
||||||
|
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -53,6 +77,8 @@ services:
|
||||||
VITE_BASE_PATH: /peregrine/
|
VITE_BASE_PATH: /peregrine/
|
||||||
ports:
|
ports:
|
||||||
- "8508:80"
|
- "8508:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,19 @@ def mark_applied(db_path: Path = DEFAULT_DB, ids: list[int] = None) -> None:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_task(db_path: Path = DEFAULT_DB, task_id: int = 0) -> bool:
|
||||||
|
"""Cancel a single queued/running task by id. Returns True if a row was updated."""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
count = conn.execute(
|
||||||
|
"UPDATE background_tasks SET status='failed', error='Cancelled by user',"
|
||||||
|
" finished_at=datetime('now') WHERE id=? AND status IN ('queued','running')",
|
||||||
|
(task_id,),
|
||||||
|
).rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
|
||||||
def kill_stuck_tasks(db_path: Path = DEFAULT_DB) -> int:
|
def kill_stuck_tasks(db_path: Path = DEFAULT_DB) -> int:
|
||||||
"""Mark all queued/running background tasks as failed. Returns count killed."""
|
"""Mark all queued/running background tasks as failed. Returns count killed."""
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@
|
||||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
<span class="sidebar__label">Settings</span>
|
<span class="sidebar__label">Settings</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
|
||||||
|
⚡ Classic
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -105,6 +108,23 @@ function exitHackerMode() {
|
||||||
localStorage.removeItem('cf-hacker-mode')
|
localStorage.removeItem('cf-hacker-mode')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
||||||
|
async function switchToClassic() {
|
||||||
|
// Persist preference via API so Streamlit reads streamlit from user.yaml
|
||||||
|
// and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
|
||||||
|
try {
|
||||||
|
await fetch(_apiBase + '/api/settings/ui-preference', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ preference: 'streamlit' }),
|
||||||
|
})
|
||||||
|
} catch { /* non-fatal — cookie below is enough for immediate redirect */ }
|
||||||
|
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
|
||||||
|
// Navigate to root (no query params) — Caddy routes to Streamlit based on cookie
|
||||||
|
window.location.href = window.location.origin + '/'
|
||||||
|
}
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
const navLinks = computed(() => [
|
||||||
{ to: '/', icon: HomeIcon, label: 'Home' },
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||||
|
|
@ -272,6 +292,29 @@ const mobileLinks = [
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar__classic-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 150ms, background 150ms;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__classic-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||||
.app-tabbar {
|
.app-tabbar {
|
||||||
display: none; /* hidden on desktop */
|
display: none; /* hidden on desktop */
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,49 @@
|
||||||
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume Highlights -->
|
||||||
|
<div
|
||||||
|
v-if="resumeSkills.length || resumeDomains.length || resumeKeywords.length"
|
||||||
|
class="resume-highlights"
|
||||||
|
>
|
||||||
|
<button class="section-toggle" @click="highlightsExpanded = !highlightsExpanded">
|
||||||
|
<span class="section-toggle__label">My Resume Highlights</span>
|
||||||
|
<span class="section-toggle__icon" aria-hidden="true">{{ highlightsExpanded ? '▲' : '▼' }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="highlightsExpanded" class="highlights-body">
|
||||||
|
<div v-if="resumeSkills.length" class="chips-group">
|
||||||
|
<span class="chips-group__label">Skills</span>
|
||||||
|
<div class="chips-wrap">
|
||||||
|
<span
|
||||||
|
v-for="s in resumeSkills" :key="s"
|
||||||
|
class="hl-chip"
|
||||||
|
:class="{ 'hl-chip--match': jobMatchSet.has(s.toLowerCase()) }"
|
||||||
|
>{{ s }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resumeDomains.length" class="chips-group">
|
||||||
|
<span class="chips-group__label">Domains</span>
|
||||||
|
<div class="chips-wrap">
|
||||||
|
<span
|
||||||
|
v-for="d in resumeDomains" :key="d"
|
||||||
|
class="hl-chip"
|
||||||
|
:class="{ 'hl-chip--match': jobMatchSet.has(d.toLowerCase()) }"
|
||||||
|
>{{ d }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resumeKeywords.length" class="chips-group">
|
||||||
|
<span class="chips-group__label">Keywords</span>
|
||||||
|
<div class="chips-wrap">
|
||||||
|
<span
|
||||||
|
v-for="k in resumeKeywords" :key="k"
|
||||||
|
class="hl-chip"
|
||||||
|
:class="{ 'hl-chip--match': jobMatchSet.has(k.toLowerCase()) }"
|
||||||
|
>{{ k }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
|
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
|
||||||
View listing ↗
|
View listing ↗
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -151,6 +194,61 @@
|
||||||
<!-- ── ATS Resume Optimizer ──────────────────────────────── -->
|
<!-- ── ATS Resume Optimizer ──────────────────────────────── -->
|
||||||
<ResumeOptimizerPanel :job-id="props.jobId" />
|
<ResumeOptimizerPanel :job-id="props.jobId" />
|
||||||
|
|
||||||
|
<!-- ── Application Q&A ───────────────────────────────────── -->
|
||||||
|
<div class="qa-section">
|
||||||
|
<button class="section-toggle" @click="qaExpanded = !qaExpanded">
|
||||||
|
<span class="section-toggle__label">Application Q&A</span>
|
||||||
|
<span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span>
|
||||||
|
<span class="section-toggle__icon" aria-hidden="true">{{ qaExpanded ? '▲' : '▼' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="qaExpanded" class="qa-body">
|
||||||
|
<p v-if="!qaItems.length" class="qa-empty">
|
||||||
|
No questions yet — add one below to get LLM-suggested answers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-for="(item, i) in qaItems" :key="item.id" class="qa-item">
|
||||||
|
<div class="qa-item__header">
|
||||||
|
<span class="qa-item__q">{{ item.question }}</span>
|
||||||
|
<button class="qa-item__del" aria-label="Remove question" @click="removeQA(i)">✕</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="qa-item__answer"
|
||||||
|
:value="item.answer"
|
||||||
|
placeholder="Your answer…"
|
||||||
|
rows="3"
|
||||||
|
@input="updateAnswer(item.id, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn-ghost btn-ghost--sm qa-suggest-btn"
|
||||||
|
:disabled="suggesting === item.id"
|
||||||
|
@click="suggestAnswer(item)"
|
||||||
|
>
|
||||||
|
{{ suggesting === item.id ? '✨ Thinking…' : '✨ Suggest' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qa-add">
|
||||||
|
<input
|
||||||
|
v-model="newQuestion"
|
||||||
|
class="qa-add__input"
|
||||||
|
placeholder="Add a question from the application…"
|
||||||
|
@keydown.enter.prevent="addQA"
|
||||||
|
/>
|
||||||
|
<button class="btn-ghost btn-ghost--sm" :disabled="!newQuestion.trim()" @click="addQA">Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="qaItems.length"
|
||||||
|
class="btn-ghost qa-save-btn"
|
||||||
|
:disabled="qaSaved || qaSaving"
|
||||||
|
@click="saveQA"
|
||||||
|
>
|
||||||
|
{{ qaSaving ? 'Saving…' : (qaSaved ? '✓ Saved' : 'Save All') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
||||||
<div class="workspace__actions">
|
<div class="workspace__actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -359,6 +457,96 @@ async function rejectListing() {
|
||||||
setTimeout(() => emit('job-removed'), 1000)
|
setTimeout(() => emit('job-removed'), 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Resume highlights ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const resumeSkills = ref<string[]>([])
|
||||||
|
const resumeDomains = ref<string[]>([])
|
||||||
|
const resumeKeywords = ref<string[]>([])
|
||||||
|
const highlightsExpanded = ref(false)
|
||||||
|
|
||||||
|
// Words from the resume that also appear in the job description text
|
||||||
|
const jobMatchSet = computed<Set<string>>(() => {
|
||||||
|
const desc = (job.value?.description ?? '').toLowerCase()
|
||||||
|
const all = [...resumeSkills.value, ...resumeDomains.value, ...resumeKeywords.value]
|
||||||
|
return new Set(all.filter(t => desc.includes(t.toLowerCase())))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchResume() {
|
||||||
|
const { data } = await useApiFetch<{ skills?: string[]; domains?: string[]; keywords?: string[] }>(
|
||||||
|
'/api/settings/resume',
|
||||||
|
)
|
||||||
|
if (!data) return
|
||||||
|
resumeSkills.value = data.skills ?? []
|
||||||
|
resumeDomains.value = data.domains ?? []
|
||||||
|
resumeKeywords.value = data.keywords ?? []
|
||||||
|
if (resumeSkills.value.length || resumeDomains.value.length || resumeKeywords.value.length) {
|
||||||
|
highlightsExpanded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Application Q&A ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface QAItem { id: string; question: string; answer: string }
|
||||||
|
|
||||||
|
const qaItems = ref<QAItem[]>([])
|
||||||
|
const qaExpanded = ref(false)
|
||||||
|
const qaSaved = ref(true)
|
||||||
|
const qaSaving = ref(false)
|
||||||
|
const newQuestion = ref('')
|
||||||
|
const suggesting = ref<string | null>(null)
|
||||||
|
|
||||||
|
function addQA() {
|
||||||
|
const q = newQuestion.value.trim()
|
||||||
|
if (!q) return
|
||||||
|
qaItems.value = [...qaItems.value, { id: crypto.randomUUID(), question: q, answer: '' }]
|
||||||
|
newQuestion.value = ''
|
||||||
|
qaSaved.value = false
|
||||||
|
qaExpanded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQA(index: number) {
|
||||||
|
qaItems.value = qaItems.value.filter((_, i) => i !== index)
|
||||||
|
qaSaved.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnswer(id: string, value: string) {
|
||||||
|
qaItems.value = qaItems.value.map(q => q.id === id ? { ...q, answer: value } : q)
|
||||||
|
qaSaved.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQA() {
|
||||||
|
qaSaving.value = true
|
||||||
|
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/qa`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items: qaItems.value }),
|
||||||
|
})
|
||||||
|
qaSaving.value = false
|
||||||
|
if (error) { showToast('Save failed — please try again'); return }
|
||||||
|
qaSaved.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestAnswer(item: QAItem) {
|
||||||
|
suggesting.value = item.id
|
||||||
|
const { data, error } = await useApiFetch<{ answer: string }>(`/api/jobs/${props.jobId}/qa/suggest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ question: item.question }),
|
||||||
|
})
|
||||||
|
suggesting.value = null
|
||||||
|
if (error || !data?.answer) { showToast('Suggestion failed — check your LLM backend'); return }
|
||||||
|
qaItems.value = qaItems.value.map(q => q.id === item.id ? { ...q, answer: data.answer } : q)
|
||||||
|
qaSaved.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQA() {
|
||||||
|
const { data } = await useApiFetch<{ items: QAItem[] }>(`/api/jobs/${props.jobId}/qa`)
|
||||||
|
if (data?.items?.length) {
|
||||||
|
qaItems.value = data.items
|
||||||
|
qaExpanded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Toast ────────────────────────────────────────────────────────────────────
|
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const toast = ref<string | null>(null)
|
const toast = ref<string | null>(null)
|
||||||
|
|
@ -406,6 +594,10 @@ onMounted(async () => {
|
||||||
await fetchJob()
|
await fetchJob()
|
||||||
loadingJob.value = false
|
loadingJob.value = false
|
||||||
|
|
||||||
|
// Load resume highlights and saved Q&A in parallel
|
||||||
|
fetchResume()
|
||||||
|
fetchQA()
|
||||||
|
|
||||||
// Check if a generation task is already in flight
|
// Check if a generation task is already in flight
|
||||||
if (clState.value === 'none') {
|
if (clState.value === 'none') {
|
||||||
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
||||||
|
|
@ -843,6 +1035,205 @@ declare module '../stores/review' {
|
||||||
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
|
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
|
||||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||||
|
|
||||||
|
/* ── Resume Highlights ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.resume-highlights {
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toggle__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toggle__icon {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlights-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|
||||||
|
.chips-group__label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
|
||||||
|
.hl-chip {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-chip--match {
|
||||||
|
background: rgba(39, 174, 96, 0.10);
|
||||||
|
border-color: rgba(39, 174, 96, 0.35);
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Application Q&A ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.qa-section {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-section > .section-toggle {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-section > .section-toggle:hover { background: var(--color-surface-alt); }
|
||||||
|
|
||||||
|
.qa-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-empty {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-item:last-of-type { border-bottom: none; }
|
||||||
|
|
||||||
|
.qa-item__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-item__q {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-item__del {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 2px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-item__del:hover { opacity: 1; color: var(--color-error); }
|
||||||
|
|
||||||
|
.qa-item__answer {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-item__answer:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-suggest-btn { align-self: flex-end; }
|
||||||
|
|
||||||
|
.qa-add {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-add__input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-add__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-add__input::placeholder { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.qa-save-btn { align-self: flex-end; }
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ export type ApiError =
|
||||||
| { kind: 'network'; message: string }
|
| { kind: 'network'; message: string }
|
||||||
| { kind: 'http'; status: number; detail: string }
|
| { kind: 'http'; status: number; detail: string }
|
||||||
|
|
||||||
|
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
||||||
|
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
||||||
export async function useApiFetch<T>(
|
export async function useApiFetch<T>(
|
||||||
url: string,
|
url: string,
|
||||||
opts?: RequestInit,
|
opts?: RequestInit,
|
||||||
): Promise<{ data: T | null; error: ApiError | null }> {
|
): Promise<{ data: T | null; error: ApiError | null }> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, opts)
|
const res = await fetch(_apiBase + url, opts)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '')
|
const detail = await res.text().catch(() => '')
|
||||||
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
||||||
|
|
@ -31,7 +34,7 @@ export function useApiSSE(
|
||||||
onComplete?: () => void,
|
onComplete?: () => void,
|
||||||
onError?: (e: Event) => void,
|
onError?: (e: Event) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const es = new EventSource(url)
|
const es = new EventSource(_apiBase + url)
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data) as Record<string, unknown>
|
const data = JSON.parse(e.data) as Record<string, unknown>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div v-if="!config.isCloud" class="field-row">
|
||||||
<label class="field-label" for="profile-inference">Inference profile</label>
|
<label class="field-label" for="profile-inference">Inference profile</label>
|
||||||
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
|
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
|
||||||
<option value="remote">Remote</option>
|
<option value="remote">Remote</option>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@
|
||||||
<div class="empty-card">
|
<div class="empty-card">
|
||||||
<h3>Upload & Parse</h3>
|
<h3>Upload & Parse</h3>
|
||||||
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
|
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
|
||||||
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
|
<input type="file" accept=".pdf,.docx,.odt" @change="handleFileSelect" ref="fileInput" />
|
||||||
|
<button
|
||||||
|
v-if="pendingFile"
|
||||||
|
@click="handleUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
style="margin-top:10px"
|
||||||
|
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
|
||||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Blank -->
|
<!-- Blank -->
|
||||||
|
|
@ -24,8 +30,8 @@
|
||||||
<p>Start with a blank form and fill in your details.</p>
|
<p>Start with a blank form and fill in your details.</p>
|
||||||
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
|
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Wizard -->
|
<!-- Wizard — self-hosted only -->
|
||||||
<div class="empty-card">
|
<div v-if="!config.isCloud" class="empty-card">
|
||||||
<h3>Run Setup Wizard</h3>
|
<h3>Run Setup Wizard</h3>
|
||||||
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
|
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
|
||||||
<RouterLink to="/setup">Open Setup Wizard →</RouterLink>
|
<RouterLink to="/setup">Open Setup Wizard →</RouterLink>
|
||||||
|
|
@ -35,6 +41,21 @@
|
||||||
|
|
||||||
<!-- Full form (when resume exists) -->
|
<!-- Full form (when resume exists) -->
|
||||||
<template v-else-if="store.hasResume">
|
<template v-else-if="store.hasResume">
|
||||||
|
<!-- Replace resume via upload -->
|
||||||
|
<section class="form-section replace-section">
|
||||||
|
<h3>Replace Resume</h3>
|
||||||
|
<p class="section-note">Upload a new PDF, DOCX, or ODT to re-parse and overwrite the current data.</p>
|
||||||
|
<input type="file" accept=".pdf,.docx,.odt" @change="handleFileSelect" ref="replaceFileInput" />
|
||||||
|
<button
|
||||||
|
v-if="pendingFile"
|
||||||
|
@click="handleUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="btn-primary"
|
||||||
|
style="margin-top:10px"
|
||||||
|
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
|
||||||
|
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Personal Information -->
|
<!-- Personal Information -->
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3>Personal Information</h3>
|
<h3>Personal Information</h3>
|
||||||
|
|
@ -221,17 +242,22 @@ import { ref, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useResumeStore } from '../../stores/settings/resume'
|
import { useResumeStore } from '../../stores/settings/resume'
|
||||||
import { useProfileStore } from '../../stores/settings/profile'
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
const store = useResumeStore()
|
const store = useResumeStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
const { loadError } = storeToRefs(store)
|
const { loadError } = storeToRefs(store)
|
||||||
const showSelfId = ref(false)
|
const showSelfId = ref(false)
|
||||||
const skillInput = ref('')
|
const skillInput = ref('')
|
||||||
const domainInput = ref('')
|
const domainInput = ref('')
|
||||||
const kwInput = ref('')
|
const kwInput = ref('')
|
||||||
const uploadError = ref<string | null>(null)
|
const uploadError = ref<string | null>(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const pendingFile = ref<File | null>(null)
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const replaceFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.load()
|
await store.load()
|
||||||
|
|
@ -246,9 +272,16 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleUpload(event: Event) {
|
function handleFileSelect(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
const file = (event.target as HTMLInputElement).files?.[0]
|
||||||
|
pendingFile.value = file ?? null
|
||||||
|
uploadError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
const file = pendingFile.value
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
uploading.value = true
|
||||||
uploadError.value = null
|
uploadError.value = null
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
@ -256,10 +289,14 @@ async function handleUpload(event: Event) {
|
||||||
'/api/settings/resume/upload',
|
'/api/settings/resume/upload',
|
||||||
{ method: 'POST', body: formData }
|
{ method: 'POST', body: formData }
|
||||||
)
|
)
|
||||||
|
uploading.value = false
|
||||||
if (error || !data?.ok) {
|
if (error || !data?.ok) {
|
||||||
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
|
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pendingFile.value = null
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
if (replaceFileInput.value) replaceFileInput.value.value = ''
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
await store.load()
|
await store.load()
|
||||||
}
|
}
|
||||||
|
|
@ -307,4 +344,5 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
|
||||||
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||||
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
||||||
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.replace-section { background: var(--color-surface-2, rgba(255,255,255,0.03)); border-radius: 8px; padding: var(--space-4, 24px); }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const devOverride = computed(() => !!config.devTierOverride)
|
||||||
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
||||||
|
|
||||||
const showSystem = computed(() => !config.isCloud)
|
const showSystem = computed(() => !config.isCloud)
|
||||||
|
const showData = computed(() => !config.isCloud)
|
||||||
const showFineTune = computed(() => {
|
const showFineTune = computed(() => {
|
||||||
if (config.isCloud) return config.tier === 'premium'
|
if (config.isCloud) return config.tier === 'premium'
|
||||||
return gpuProfiles.includes(config.inferenceProfile)
|
return gpuProfiles.includes(config.inferenceProfile)
|
||||||
|
|
@ -65,7 +66,7 @@ const allGroups = [
|
||||||
]},
|
]},
|
||||||
{ label: 'Account', items: [
|
{ label: 'Account', items: [
|
||||||
{ key: 'license', path: '/settings/license', label: 'License', show: true },
|
{ key: 'license', path: '/settings/license', label: 'License', show: true },
|
||||||
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
|
{ key: 'data', path: '/settings/data', label: 'Data', show: showData },
|
||||||
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
|
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
|
||||||
]},
|
]},
|
||||||
{ label: 'Dev', items: [
|
{ label: 'Dev', items: [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue