feat: start resume sessions in new flow

This commit is contained in:
Codex
2026-08-06 10:49:25 +08:00
parent 671a9b9419
commit 1c762fd092
18 changed files with 189 additions and 556 deletions
-14
View File
@@ -5,7 +5,6 @@ import AppHeader from './components/AppHeader.vue'
import ComposerBar from './components/ComposerBar.vue'
import EditResumePreview from './components/EditResumePreview.vue'
import FeatureNavigation from './components/FeatureNavigation.vue'
import ResumeImportPanel from './components/ResumeImportPanel.vue'
import { useResumeAgent } from './composables/useResumeAgent'
import { useResumeDocument } from './composables/useResumeDocument'
@@ -52,7 +51,6 @@ const remoteRefreshBlocked = computed(
!sessionId.value ||
isBusy.value ||
Boolean(resumeDocument.busyEntryId.value) ||
resumeDocument.importBusy.value ||
resumeDocument.skillsBusy.value ||
resumeDocument.summaryBusy.value,
)
@@ -94,8 +92,6 @@ function handleVisibilityChange() {
const stageLabels: Record<string, string> = {
starting: '准备会话',
PRIVACY_CONSENT: '隐私确认',
RESUME_SOURCE_SELECT: '选择创建方式',
RESUME_IMPORT_UPLOAD: '导入简历',
PHONE_SELECTION: '手机号授权',
MANUAL_PHONE_INPUT: '填写手机号',
PERSONAL_INFO: '基本信息',
@@ -118,10 +114,6 @@ watch(sessionId, (value, previous) => {
if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns()
})
watch(() => resumeDocument.resumeImport.value?.status, (status) => {
if (status === 'applied') void refreshTimeline()
})
watch(remoteRefreshBlocked, (blocked) => {
if (blocked) cancelRemoteRefresh()
}, { flush: 'sync' })
@@ -248,12 +240,6 @@ onBeforeUnmount(() => {
</span>
</header>
<ResumeImportPanel
v-if="stage === 'RESUME_IMPORT_UPLOAD'"
:document="resumeDocument"
:disabled="!sessionId || initializing"
/>
<AgentTimeline
:timeline="timeline"
:initializing="initializing"
-36
View File
@@ -3,7 +3,6 @@ import type {
ComponentEventInput,
MessageInput,
ResumeAgentEnvelope,
ResumeImportView,
OptimizationRunView,
ResumePatchOperationInput,
SkillRecommendationCandidate,
@@ -186,41 +185,6 @@ export const resumeAgentApi = {
})
},
uploadResumeImport(sessionId: string, file: File, signal?: AbortSignal) {
const form = new FormData()
form.append("file", file)
return request<ResumeImportView>(sessionPath(sessionId, "/resume-imports"), {
method: "POST",
body: form,
signal,
})
},
getResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
return request<ResumeImportView>(
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
{ signal },
)
},
applyResumeImport(
sessionId: string,
importId: string,
expectedRevision: number,
signal?: AbortSignal,
) {
return request<ResumeAgentEnvelope>(
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}/apply`),
{ method: "POST", body: JSON.stringify({ expected_revision: expectedRevision }), signal },
)
},
cancelResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
return request<ResumeImportView>(
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
{ method: "DELETE", signal },
)
},
deleteSession(sessionId: string, signal?: AbortSignal) {
return request<Record<string, unknown>>(sessionPath(sessionId), {
method: 'DELETE',
@@ -1,145 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import type { useResumeDocument } from '../composables/useResumeDocument'
const props = defineProps<{
document: ReturnType<typeof useResumeDocument>
disabled?: boolean
}>()
const input = ref<HTMLInputElement | null>(null)
const selectedName = ref('')
const accepted = '.pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const reviewCount = computed(() => props.document.resumeImport.value?.field_reviews.length ?? 0)
const importStatus = computed(() => props.document.resumeImport.value?.status)
const hasContent = computed(() => {
const content = props.document.resume.value?.content
if (!content) return false
if (String(content.basics?.name || '').trim()) return true
if ((content.skill_groups || []).length) return true
return (content.sections || []).some((section) => (section.items || []).length > 0)
})
const cannotImport = computed(() => Boolean(props.disabled || hasContent.value || props.document.importBusy.value))
function selectFile() {
if (!cannotImport.value) input.value?.click()
}
function onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file || cannotImport.value) return
selectedName.value = file.name
void props.document.uploadImport(file)
}
function clearSelection() {
selectedName.value = ''
if (input.value) input.value.value = ''
}
async function cancel() {
await props.document.cancelImport()
clearSelection()
}
onBeforeUnmount(() => {
// The panel only lives during RESUME_IMPORT_UPLOAD. When it unmounts (stage
// advanced or 重新开始 reset the session) the import view must not leak into
// the next session — a stale "导入完成" card blocks selecting a new file.
props.document.resumeImport.value = null
})
</script>
<template>
<section class="resume-import" aria-label="简历导入">
<input
ref="input"
class="resume-import__input"
type="file"
:accept="accepted"
:disabled="cannotImport"
@change="onFileChange"
/>
<template v-if="!document.resumeImport.value || importStatus === 'cancelled'">
<div class="resume-import__copy">
<p>简历导入</p>
<h2>导入已有简历</h2>
<span>支持 PDF / DOCX不超过 10 MB</span>
</div>
<button
type="button"
class="resume-import__select"
:disabled="cannotImport"
@click="selectFile"
>
{{ document.importBusy.value ? '解析中...' : '选择文件' }}
</button>
<small v-if="hasContent">简历预览已有内容如需导入请先从头部重新开始</small>
<small v-else-if="selectedName">{{ selectedName }}</small>
</template>
<template v-else-if="importStatus === 'awaiting_review'">
<div class="resume-import__copy">
<p>导入预览</p>
<h2>{{ document.resumeImport.value.file_name }}</h2>
<span>已解析出 {{ reviewCount }} 个字段确认后应用到简历</span>
</div>
<div class="resume-import__actions">
<button
type="button"
class="resume-import__button resume-import__button--primary"
:disabled="document.importBusy.value"
@click="document.applyImport"
>
{{ document.importBusy.value ? '应用中...' : '应用到简历' }}
</button>
<button
type="button"
class="resume-import__button"
:disabled="document.importBusy.value"
@click="cancel"
>
放弃导入
</button>
</div>
</template>
<template v-else-if="importStatus === 'applied'">
<div class="resume-import__copy">
<p>导入完成</p>
<h2>{{ document.resumeImport.value.file_name }}</h2>
<span>导入内容已进入右侧简历预览</span>
</div>
<button type="button" class="resume-import__button" @click="clearSelection">
完成
</button>
</template>
</section>
</template>
<style scoped>
.resume-import {
display: grid;
gap: 12px;
margin: 0 0 20px 59px;
padding: 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #f9fdfc;
}
.resume-import__input { display: none; }
.resume-import__copy { display: grid; gap: 4px; min-width: 0; }
.resume-import__copy p { margin: 0; color: var(--brand-dark); font-family: ui-monospace, Consolas, monospace; font-size: 9px; font-weight: 800; }
.resume-import__copy h2 { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; line-height: 1.35; }
.resume-import__copy span, .resume-import small { color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
.resume-import__select, .resume-import__button { min-height: 34px; width: fit-content; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 11px; font-weight: 750; }
.resume-import__select:hover:not(:disabled), .resume-import__button:hover:not(:disabled) { border-color: #8fc4c1; color: var(--ink); background: var(--surface-muted); }
.resume-import__select:disabled, .resume-import__button:disabled { opacity: .55; }
.resume-import__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.resume-import__button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.resume-import__button--primary:hover:not(:disabled) { color: #fff; border-color: #146e73; background: #146e73; }
@media (max-width: 760px) { .resume-import { margin-left: 38px; } }
</style>
+15 -1
View File
@@ -143,6 +143,20 @@ function normalizeBlock(raw: RawTimelineBlock, index: number): TimelineBlock {
}
}
function isRemovedResumeSourceBlock(block: TimelineBlock): boolean {
if (block.type === 'text') {
return ['请选择开始方式。', '请选择需要导入的 PDF 或 DOCX 简历。'].includes(block.text || '')
}
if (block.type !== 'component' || block.component !== 'choice_chips') return false
const options = Array.isArray(block.data.options) ? block.data.options : []
const values = new Set(
options
.map((option) => asString(asRecord(option).value))
.filter((value): value is string => Boolean(value)),
)
return values.has('import') && values.has('manual')
}
function normalizeComposer(envelope: ResumeAgentEnvelope, turns: unknown): ComposerConfig {
const timelineRecord = asRecord(envelope.timeline)
const gate = asRecord(envelope.gate)
@@ -205,7 +219,7 @@ export function normalizeResumeAgentResponse(response: ResumeAgentEnvelope): Nor
: typeof latestTurn.sequence === 'number'
? latestTurn.sequence
: 0,
timeline: rawBlocks.map(normalizeBlock),
timeline: rawBlocks.map(normalizeBlock).filter((block) => !isRemovedResumeSourceBlock(block)),
composer: normalizeComposer(envelope, rawTimeline),
missingFields: Array.isArray(envelope.missing_fields)
? envelope.missing_fields.filter((item): item is string => typeof item === 'string')
@@ -3,7 +3,6 @@ import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent'
import type {
OptimizationRunView,
ResumeAgentEnvelope,
ResumeImportView,
ResumePatchOperationInput,
ResumeView,
SkillRecommendationCandidate,
@@ -20,8 +19,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
const resume = ref<ResumeView | null>(null)
const busyEntryId = ref('')
const errorMessage = ref('')
const resumeImport = ref<ResumeImportView | null>(null)
const importBusy = ref(false)
const optimizationRuns = ref<Record<string, OptimizationRunView>>({})
const skillCandidates = ref<SkillRecommendationCandidate[]>([])
const skillsBusy = ref(false)
@@ -49,9 +46,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
if (error.status === 403 && error.payload?.error?.code === 'deep_requires_vip') {
return '深度优化为 VIP 功能,升级后可继续进行多轮追问与改写。'
}
if (error.payload?.error?.code === 'resume_import_not_allowed') {
return '简历预览已有内容,如需导入请先从头部重新开始。'
}
return error.message
}
if (error instanceof Error && error.message) return error.message
@@ -189,62 +183,10 @@ export function useResumeDocument(sessionId: Ref<string>) {
summaryBusy.value = false
}
}
async function uploadImport(file: File) {
if (!sessionId.value || importBusy.value) return
if (resume.value) {
errorMessage.value = '简历预览已有内容,如需导入请先从头部重新开始。'
return
}
importBusy.value = true
errorMessage.value = ''
try {
resumeImport.value = await resumeAgentApi.uploadResumeImport(sessionId.value, file)
} catch (error) {
errorMessage.value = formatError(error)
} finally {
importBusy.value = false
}
}
async function applyImport() {
if (!sessionId.value || !resumeImport.value || importBusy.value) return
importBusy.value = true
errorMessage.value = ''
try {
const response = await resumeAgentApi.applyResumeImport(
sessionId.value,
resumeImport.value.id,
resume.value?.revision ?? 0,
)
syncFrom(response)
resumeImport.value = { ...resumeImport.value, status: 'applied' }
} catch (error) {
errorMessage.value = formatError(error)
} finally {
importBusy.value = false
}
}
async function cancelImport() {
if (!sessionId.value || !resumeImport.value || importBusy.value) return
importBusy.value = true
errorMessage.value = ''
try {
await resumeAgentApi.cancelResumeImport(sessionId.value, resumeImport.value.id)
resumeImport.value = null
} catch (error) {
errorMessage.value = formatError(error)
} finally {
importBusy.value = false
}
}
return {
resume,
busyEntryId,
errorMessage,
resumeImport,
importBusy,
optimizationRuns,
skillCandidates,
skillsBusy,
@@ -253,9 +195,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
syncFrom,
setTargetPosition,
restoreOptimizationRuns,
uploadImport,
applyImport,
cancelImport,
updateBasics: (fields: Record<string, string>) => patch({ type: 'update_basics', fields }),
updateSkillGroups: (skills: string[]) => patch({ type: 'update_skill_groups', skills }, 'skills'),
updateProfileSummary: (content: string) =>
@@ -304,4 +243,3 @@ export function useResumeDocument(sessionId: Ref<string>) {
},
}
}
+1 -29
View File
@@ -277,34 +277,6 @@ export interface ResumeDocument {
} | null
}
export interface ResumeImportEvidence {
page?: number | null
paragraph?: number | null
text: string
}
export interface ResumeImportFieldReview {
field_path: string
value: unknown
confidence: number
status: "needs_review" | "verified"
evidence: ResumeImportEvidence[]
}
export interface ResumeImportView {
id: string
session_id: string
file_name: string
mime_type: string
size_bytes: number
sha256: string
status: "awaiting_review" | "applied" | "failed" | "cancelled"
document: ResumeDocument | null
field_reviews: ResumeImportFieldReview[]
error_code?: string | null
created_at: string
updated_at: string
}
export interface SkillRecommendationCandidate {
skill: string
category: string
@@ -339,4 +311,4 @@ export interface BuilderStreamEvent {
message?: string
status_code?: number
}
}
}