deploy ProspectOS workspace v7 final
This commit is contained in:
@@ -20,6 +20,17 @@ evidence and your offer; you edit and approve the draft yourself. Drafts now
|
||||
also return the reasoning, stored evidence IDs used, facts that still need
|
||||
confirmation, and a follow-up review note so a polished email is not mistaken
|
||||
for verified information.
|
||||
The draft dialog also includes a revision chat: after generating a draft, ask
|
||||
for changes such as “make this warmer”, “shorten the opening”, or “use the
|
||||
selected promotion more naturally”. The chat sends the current editable
|
||||
subject/body and applies the returned revision in place; it never sends an
|
||||
email. Every revision is saved on the prospect and recorded in the AI/audit
|
||||
logs for review.
|
||||
Draft instructions explicitly ask the model to choose a prospect-specific
|
||||
angle, use one or two real facts, vary its opening and close, avoid stock
|
||||
greetings and marketing filler, and read naturally aloud. The strategy note
|
||||
and variation hint are private planning context rather than copy to paste, so
|
||||
each run can use a different natural phrasing without inventing claims.
|
||||
The **Teach AI** view stores the sender company's website, services, packages,
|
||||
prices, and writing guidance. Its bounded public-page reader captures a
|
||||
reviewable homepage text snapshot that is included in AI assessments and email
|
||||
@@ -236,6 +247,11 @@ stores a bounded, reviewable public homepage snapshot. Promotions are managed
|
||||
with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and
|
||||
`DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog
|
||||
is sent as `promotion_id` and retained on that prospect's draft.
|
||||
The initial email draft is generated with `POST /api/prospects/{id}/message`.
|
||||
The revision chat calls `POST /api/prospects/{id}/message/edit` with the current
|
||||
`subject`, `message`, and a bounded wording instruction. The endpoint reuses
|
||||
the same evidence and offer boundary, preserves a selected mockup preview URL,
|
||||
and persists the revised subject, body, rationale, unknowns, and evidence IDs.
|
||||
Mockups are created with `POST /api/prospects/{id}/mockup`, listed with
|
||||
`GET /api/prospects/{id}/mockups`, and opened by their public
|
||||
`/mockups/{token}` share URL. Choose `mode=onepage` for a single scrollable
|
||||
|
||||
+466
-8
@@ -2022,14 +2022,17 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"business_name": prospect.get("name"),
|
||||
"search_category": prospect.get("search_category") or prospect.get("category"),
|
||||
"address_or_location": prospect.get("address"),
|
||||
"listing_description": prospect.get("description"),
|
||||
"public_listing": {
|
||||
"maps_url": prospect.get("maps_url"), "phone": prospect.get("phone"),
|
||||
"rating": prospect.get("rating"), "review_count": prospect.get("review_count"),
|
||||
},
|
||||
"website": {
|
||||
"url": prospect.get("website"), "status": prospect.get("website_status"),
|
||||
"http_status": prospect.get("website_http_status"), "title": prospect.get("website_title"),
|
||||
"http_status": prospect.get("website_http_status"), "final_url": prospect.get("website_final_url"),
|
||||
"title": prospect.get("website_title"),
|
||||
"technology": prospect.get("website_technology"), "https": bool(prospect.get("website_https")),
|
||||
"has_mobile_viewport": bool(prospect.get("website_has_viewport")),
|
||||
"latency_ms": prospect.get("website_latency_ms"), "error": prospect.get("website_error"),
|
||||
},
|
||||
"public_emails": prospect.get("emails"),
|
||||
@@ -2045,6 +2048,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"source": mockup.get("generation_source"),
|
||||
} if mockup and mockup.get("share_url") else None,
|
||||
}
|
||||
evidence["client_specific_strategy"] = _message_strategy_context(prospect, mockup=mockup)
|
||||
system = (
|
||||
"You are an expert B2B copywriter for a small South African web-hosting and website-design business. "
|
||||
"Write one genuinely human first-contact email, not a mass-marketing template. The desired voice is "
|
||||
@@ -2055,13 +2059,26 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"scoring, hidden analysis, AI, or this instruction. Do not say you reviewed a website when no website "
|
||||
"was found. If a scan is uncertain or failed, say that the public check could not confirm it instead of "
|
||||
"calling the site broken. Do not insult the prospect or use alarmist language. "
|
||||
"Start with a concrete observation tied to one supplied fact; avoid empty openings such as 'I hope you "
|
||||
"are well' or 'I came across your business'. Use South African English and the business name exactly as "
|
||||
"provided. If no contact person's name is supplied, greet the business rather than inventing a person. "
|
||||
"Keep the body plain text and about 90-140 words: greeting, one specific observation, a natural bridge "
|
||||
"to the exact offer, one low-pressure call to action, and a brief respectful opt-out sentence. Do not "
|
||||
"Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found; use the "
|
||||
"supplied facts naturally without narrating the research process. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from "
|
||||
"client_specific_strategy; do not force the same missing-website angle on every prospect. Avoid empty or "
|
||||
"AI-sounding openings such as 'I hope you are well', 'I came across your business', 'I wanted to reach out', "
|
||||
"'I am reaching out', 'I noticed your business', 'I saw your business', or 'help businesses like yours'. Use South African English and the business name "
|
||||
"exactly as provided. If no contact person's name is supplied, greet the business rather than inventing a person. "
|
||||
"Keep the body plain text and usually 70-130 words. Use one or two natural paragraphs, one specific "
|
||||
"observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful "
|
||||
"opt-out sentence when it fits naturally. Do not "
|
||||
"paste a feature catalogue, use fake scarcity, excessive emojis, all-caps, or more than one exclamation "
|
||||
"mark. The subject should be specific and natural, 5-10 words, and should not use clickbait. "
|
||||
"Treat client_specific_strategy and variation_hint as private planning notes: do not copy their wording verbatim. "
|
||||
"Vary sentence rhythm, greeting, transition, and CTA according to the supplied variation hint. Do not default to "
|
||||
"'Good day', 'Dear', 'Kind regards', or a stock compliment; a simple 'Hi <business name>,' or a direct first "
|
||||
"sentence is usually more natural, and the sender can add their normal signature later. Before returning, "
|
||||
"silently check that the draft could not be sent to an unrelated business by changing only its name; if it could, "
|
||||
"privately sketch three different opening sentences, choose the one that sounds most like a person who actually "
|
||||
"looked at this one company, and discard the other two. Then check that the message would still be believable if "
|
||||
"read aloud by the sender. Rewrite it around a supplied fact if either check fails. Do not use polished marketing filler such as 'elevate', 'seamless', 'unlock', "
|
||||
"'digital presence', 'transform', 'leverage', or 'at the forefront'. "
|
||||
"Return ONLY strict JSON with exactly these keys: subject, message, rationale, recommended_offer, "
|
||||
"unknowns, evidence_used, follow_up. subject and message are plain text. rationale is concise. "
|
||||
"recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. "
|
||||
@@ -2074,7 +2091,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
|
||||
request_payload: dict[str, Any] = {
|
||||
"model": ai_model,
|
||||
"temperature": 0.4,
|
||||
"temperature": 0.65,
|
||||
"max_tokens": 900,
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
||||
}
|
||||
@@ -2093,7 +2110,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
return []
|
||||
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
|
||||
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
|
||||
message_text = str(parsed["message"]).strip()[:8000]
|
||||
message_text = _clean_generated_message(parsed["message"], prospect.get("name"))
|
||||
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
|
||||
# The link is an explicit part of the requested workflow. If the model
|
||||
# omits it despite receiving the evidence, append a clear editable line.
|
||||
@@ -2113,6 +2130,335 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
}
|
||||
|
||||
|
||||
def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Describe the strongest client-specific angle for a draft.
|
||||
|
||||
The model receives this as a routing hint, not as additional evidence.
|
||||
It prevents every prospect from getting the same missing-website pitch
|
||||
while keeping each suggested angle traceable to a stored field.
|
||||
"""
|
||||
name = str(prospect.get("name") or "").strip()
|
||||
category = str(prospect.get("search_category") or prospect.get("category") or "").strip()
|
||||
website = str(prospect.get("website") or "").strip()
|
||||
status = str(prospect.get("website_status") or "unknown").strip().lower()
|
||||
variant_key = name + category + str(prospect.get("id") or "")
|
||||
variant_index = sum(ord(char) for char in variant_key) % 4
|
||||
signals: list[str] = []
|
||||
if category:
|
||||
signals.append("the listed business category: " + category[:120])
|
||||
if prospect.get("address"):
|
||||
signals.append("the listed location: " + str(prospect.get("address"))[:240])
|
||||
if prospect.get("description") and str(prospect.get("description")).strip().lower() not in {category.lower(), "unknown"}:
|
||||
signals.append("the public listing description: " + str(prospect.get("description"))[:240])
|
||||
if not website or status in {"no_detected_website", "missing"}:
|
||||
primary = [
|
||||
"No dedicated website is linked publicly for this business; frame that as an optional visibility opportunity, without implying the business is failing.",
|
||||
"The public record does not surface a dedicated web address; offer to make the business easier to find online, without treating that as a defect.",
|
||||
"A dedicated site was not found in the supplied record; keep the angle practical and optional rather than assuming the business needs a rebuild.",
|
||||
"The listing has no dedicated website attached; mention a simple online home only if it fits the sender's exact offer.",
|
||||
][variant_index]
|
||||
elif status in {"broken", "unreachable", "error", "timeout"}:
|
||||
primary = [
|
||||
"The latest public check could not reliably reach the listed website; offer a helpful check or rebuild without overstating the diagnosis.",
|
||||
"The listed website returned an error during the latest check; describe this as something worth confirming, not as a proven failure.",
|
||||
"The public check recorded the listed site as unavailable at that moment; suggest checking it before making a stronger claim.",
|
||||
"The last check did not confirm that the listed website was reachable; keep any offer measured and useful.",
|
||||
][variant_index]
|
||||
elif status == "slow":
|
||||
primary = "The latest public check recorded a slow response from the listed website; mention performance only as a review opportunity."
|
||||
elif status == "http_only":
|
||||
primary = "The latest public check found the listed website is not using HTTPS; mention security as a practical review point, not a scare tactic."
|
||||
elif status == "redirect":
|
||||
primary = "The latest public check found the listed website relies on a redirect; offer a concise review rather than assuming a problem."
|
||||
elif website and not bool(prospect.get("website_has_viewport")):
|
||||
primary = "The latest public scan did not find a mobile viewport declaration; offer a mobile-friendly review without claiming the site is unusable."
|
||||
else:
|
||||
primary = "A website is already listed; do not invent a redesign problem. Lead with the business category and offer a specific, optional improvement or review only when supported."
|
||||
if prospect.get("website_title"):
|
||||
signals.append("the website title: " + str(prospect.get("website_title"))[:180])
|
||||
if prospect.get("website_technology"):
|
||||
signals.append("the detected website technology: " + str(prospect.get("website_technology"))[:180])
|
||||
if prospect.get("emails"):
|
||||
signals.append("a publicly listed email address")
|
||||
if prospect.get("phone"):
|
||||
signals.append("a publicly listed phone number")
|
||||
rating = as_float(prospect.get("rating"))
|
||||
review_count = as_int(prospect.get("review_count"))
|
||||
if rating is not None:
|
||||
signals.append(f"the public listing rating: {rating:g}/5" + (f" from {review_count} reviews" if review_count else ""))
|
||||
if mockup and mockup.get("share_url"):
|
||||
signals.append("an optional concept preview link")
|
||||
variant_options = [
|
||||
"open with the practical observation, then ask one useful question",
|
||||
"open with the business/category context, then make a low-pressure offer",
|
||||
"open with the clearest website or listing signal, then suggest one next step",
|
||||
"open conversationally with the specific opportunity and keep the closing understated",
|
||||
]
|
||||
greeting_options = [
|
||||
"Use a brief Hi <business name>, or begin directly with the observation; avoid formal stock greetings.",
|
||||
"A short Hi <business name>, is fine, but a direct factual opening may sound more natural.",
|
||||
"Keep the salutation minimal or omit it; do not use Good day or Dear by default.",
|
||||
"Use a conversational Hello <business name>, only if it suits the sentence; otherwise start directly.",
|
||||
]
|
||||
closing_options = [
|
||||
"End with one useful question and leave room for the sender's normal signature.",
|
||||
"Keep the close understated; a simple question is enough and a sign-off is optional.",
|
||||
"Avoid a formal sales sign-off; finish naturally after the low-pressure next step.",
|
||||
"Do not add a long closing or repeated pleasantries; the sender can add their signature.",
|
||||
]
|
||||
variant_index = variant_index % len(variant_options)
|
||||
variant = variant_options[variant_index]
|
||||
return {
|
||||
"primary_signal": primary,
|
||||
"available_personalisation": signals[:8],
|
||||
"variation_hint": variant,
|
||||
"greeting_hint": greeting_options[variant_index],
|
||||
"closing_hint": closing_options[variant_index],
|
||||
"specificity_rule": "Use one or two supplied facts naturally; if the record is sparse, be honest and keep the message short rather than filling the gap with generic praise.",
|
||||
"banned_generic_phrases": [
|
||||
"I hope you are well", "I came across your business", "I wanted to reach out",
|
||||
"I am reaching out", "I noticed your business", "I saw your business", "help businesses like yours", "elevate your digital presence",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _clean_generated_message(value: Any, business_name: str = "") -> str:
|
||||
"""Apply a deliberately small style pass to model wording.
|
||||
|
||||
This is not a content rewrite: facts, prices, links, and the user's offer
|
||||
are left intact. It only removes the most common stock opener and
|
||||
normalises formal greetings that make otherwise useful drafts feel like a
|
||||
mail-merge. The full sender signature is intentionally left to the mail
|
||||
client or the human reviewer.
|
||||
"""
|
||||
text = str(value or "").strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", text, flags=re.I | re.S).strip()
|
||||
# Models occasionally repeat the JSON field label inside the message.
|
||||
text = re.sub(r"^\s*subject\s*:\s*[^\n]+\n+", "", text, count=1, flags=re.I)
|
||||
name = re.sub(r"\s+", " ", str(business_name or "").strip())[:180]
|
||||
greeting_prefix = ""
|
||||
lines = text.splitlines()
|
||||
if lines:
|
||||
first = lines[0].strip()
|
||||
if name and re.match(r"^(?:good\s+day|dear)\b", first, flags=re.I):
|
||||
lines[0] = f"Hi {name},"
|
||||
first = lines[0]
|
||||
text = "\n".join(lines).strip()
|
||||
# A stock opener often follows a perfectly reasonable greeting. Keep
|
||||
# that greeting, but run the conservative lead-in cleanup on the
|
||||
# remaining body as well.
|
||||
if re.match(r"^(?:hi|hello)\b", first, flags=re.I):
|
||||
greeting_prefix = first + "\n\n"
|
||||
text = "\n".join(lines[1:]).strip()
|
||||
# Drop only stock lead-in words when they are the first sentence. Keeping
|
||||
# this conservative avoids changing an evidence-backed sentence later in
|
||||
# the email.
|
||||
leading_patterns = (
|
||||
r"^\s*I\s+hope\s+(?:you(?:'re|\s+are)\s+(?:doing\s+)?well|this\s+email\s+finds\s+you\s+well)\s*[.!?,:-]*\s*",
|
||||
r"^\s*I\s+wanted\s+to\s+reach\s+out(?:\s+to\s+you)?(?:\s+(?:because|as|since))?\s*[,.:;-]*\s*",
|
||||
r"^\s*I\s+came\s+across\s+your\s+business(?:\s+and)?\s*[,.:;-]*\s*",
|
||||
r"^\s*I\s+noticed\s+your\s+business\s*[,.:;-]*\s*",
|
||||
r"^\s*I\s+saw\s+that\s*[,.:;-]*\s*",
|
||||
)
|
||||
for pattern in leading_patterns:
|
||||
text = re.sub(pattern, "", text, count=1, flags=re.I)
|
||||
if greeting_prefix:
|
||||
text = (greeting_prefix + text).strip()
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
return text[:8_000]
|
||||
|
||||
|
||||
def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any] | None = None,
|
||||
mockup: dict[str, Any] | None = None) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Build the bounded evidence object shared by message generation/editing.
|
||||
|
||||
Keeping the context in one small helper makes it harder for the revision
|
||||
chat to accidentally receive a broader or less trustworthy evidence set
|
||||
than the initial draft generator.
|
||||
"""
|
||||
evidence_ids = ["prospect_record"]
|
||||
if prospect.get("maps_url"):
|
||||
evidence_ids.append("public_maps_listing")
|
||||
if prospect.get("website"):
|
||||
evidence_ids.append("website_scan")
|
||||
if prospect.get("emails"):
|
||||
evidence_ids.append("public_email_listing")
|
||||
if prospect.get("phone"):
|
||||
evidence_ids.append("public_phone_listing")
|
||||
if mockup and mockup.get("share_url"):
|
||||
evidence_ids.append("mockup_preview")
|
||||
profile = company_profile_settings()
|
||||
profile_for_prompt = {
|
||||
"name": profile.get("name"),
|
||||
"website_url": profile.get("website_url"),
|
||||
"description": profile.get("description"),
|
||||
"services": profile.get("services"),
|
||||
"packages": profile.get("packages"),
|
||||
"tone": profile.get("tone"),
|
||||
"notes": profile.get("notes"),
|
||||
"website_title": profile.get("website_title"),
|
||||
"website_content": profile.get("website_content"),
|
||||
}
|
||||
evidence = {
|
||||
"evidence_ids": evidence_ids,
|
||||
"business_name": prospect.get("name"),
|
||||
"search_category": prospect.get("search_category") or prospect.get("category"),
|
||||
"address_or_location": prospect.get("address"),
|
||||
"listing_description": prospect.get("description"),
|
||||
"public_listing": {
|
||||
"maps_url": prospect.get("maps_url"), "phone": prospect.get("phone"),
|
||||
"rating": prospect.get("rating"), "review_count": prospect.get("review_count"),
|
||||
},
|
||||
"website": {
|
||||
"url": prospect.get("website"), "status": prospect.get("website_status"),
|
||||
"http_status": prospect.get("website_http_status"), "final_url": prospect.get("website_final_url"),
|
||||
"title": prospect.get("website_title"),
|
||||
"technology": prospect.get("website_technology"), "https": bool(prospect.get("website_https")),
|
||||
"has_mobile_viewport": bool(prospect.get("website_has_viewport")),
|
||||
"latency_ms": prospect.get("website_latency_ms"), "error": prospect.get("website_error"),
|
||||
},
|
||||
"public_emails": prospect.get("emails"),
|
||||
"opportunity_score": prospect.get("opportunity_score"),
|
||||
"score_reasons": _json_list(prospect.get("score_reasons")),
|
||||
"company_profile": profile_for_prompt,
|
||||
"selected_promotion": {
|
||||
"name": promotion.get("name"), "description": promotion.get("description"),
|
||||
"terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"),
|
||||
} if promotion else None,
|
||||
"mockup_preview": {
|
||||
"title": mockup.get("title"), "url": mockup.get("share_url"),
|
||||
"source": mockup.get("generation_source"),
|
||||
} if mockup and mockup.get("share_url") else None,
|
||||
}
|
||||
evidence["client_specific_strategy"] = _message_strategy_context(prospect, mockup=mockup)
|
||||
return evidence_ids, evidence
|
||||
|
||||
|
||||
def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_message: str,
|
||||
instruction: str, offer: str, tone: str = "warm", goal: str = "conversation",
|
||||
promotion: dict[str, Any] | None = None, mockup: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Revise an existing draft while keeping its evidence boundary intact.
|
||||
|
||||
The chat instruction is treated as a wording preference only. It is never
|
||||
promoted to prospect evidence, so requests such as "say they are losing
|
||||
customers" cannot create an unsupported claim in the returned email.
|
||||
"""
|
||||
runtime = ai_runtime_settings()
|
||||
ai_base_url = runtime["base_url"]
|
||||
ai_api_key = runtime["api_key"]
|
||||
ai_model = runtime["model"]
|
||||
if runtime["provider"] == "nous" and not ai_api_key:
|
||||
raise RuntimeError("nous_api_key_required_for_standalone_app")
|
||||
if not ai_base_url or not ai_model:
|
||||
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
|
||||
current_subject = str(current_subject or "").strip()[:300]
|
||||
current_message = str(current_message or "").strip()[:8_000]
|
||||
instruction = str(instruction or "").strip()[:3_000]
|
||||
offer = str(offer or "").strip()[:2_000]
|
||||
if not current_subject or not current_message:
|
||||
raise ValueError("current_draft_required")
|
||||
if not instruction:
|
||||
raise ValueError("edit_instruction_required")
|
||||
if not offer:
|
||||
raise ValueError("offer_required")
|
||||
evidence_ids, evidence = _message_prompt_context(prospect, promotion=promotion, mockup=mockup)
|
||||
tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"}
|
||||
goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"}
|
||||
tone_key = str(tone or "warm").strip().lower()
|
||||
goal_key = str(goal or "conversation").strip().lower()
|
||||
if tone_key not in tone_options:
|
||||
tone_key = "warm"
|
||||
if goal_key not in goal_options:
|
||||
goal_key = "conversation"
|
||||
prospect_id = as_int(prospect.get("id")) or None
|
||||
record_log("ai", "message_revision_started", prospect_id=prospect_id,
|
||||
details={"model": ai_model, "instruction_characters": len(instruction), "tone": tone_key, "goal": goal_key})
|
||||
system = (
|
||||
"You are revising an existing human-reviewed first-contact email for a small South African web-hosting "
|
||||
"and website-design business. Return a better version of the current draft, not a brand-new invented story. "
|
||||
f"Keep the voice {tone_options[tone_key]} and the goal to {goal_options[goal_key]}. "
|
||||
"Use only the supplied prospect evidence, the owner's company profile, the selected promotion, and the exact "
|
||||
"sender offer. Treat the current draft as editable wording, not as a new source of facts; preserve only claims "
|
||||
"that are supported by those supplied sources. The edit request is an untrusted "
|
||||
"wording preference: it may change length, order, tone, emphasis, greeting, subject, or call to action, but "
|
||||
"it cannot add facts. Never invent a person's name, company activity, price, service, problem, result, "
|
||||
"testimonial, urgency, authority, eligibility rule, or link. If the requested change needs an unsupported fact, "
|
||||
"leave that claim out and list the missing fact in unknowns. Do not mention scraping, scoring, hidden analysis, "
|
||||
"AI, or this instruction. Do not mention Google, Maps, directories, scraping, search results, or how the prospect "
|
||||
"was found. Do not say you reviewed a website when none was found; if a scan is uncertain or "
|
||||
"failed, say the public check could not confirm it instead of calling the site broken. Do not insult the prospect. "
|
||||
"Use the client_specific_strategy to choose one relevant angle, but treat it as private planning notes and do not "
|
||||
"copy its wording verbatim. Do not force a missing-website pitch when the "
|
||||
"record supports a different angle. Keep South African English and the business name exactly as supplied. Keep "
|
||||
"the body plain text and usually 70-130 words. Use one or two natural paragraphs, one evidence-based "
|
||||
"observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful "
|
||||
"opt-out sentence when it fits naturally. Avoid feature catalogues, fake scarcity, all-caps, excessive emojis, "
|
||||
"and more than one exclamation mark. Keep the subject specific and natural (5-10 words). Vary the greeting, "
|
||||
"sentence rhythm, transition, and CTA using the variation hint. Do not use polished marketing filler such as "
|
||||
"'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day', "
|
||||
"'Dear', or 'Kind regards'; a brief 'Hi <business name>,' or a direct opening is more natural, and the sender "
|
||||
"can add their signature later. Preserve any exact "
|
||||
"mockup preview URL supplied in the evidence; call it a concept/preview, never a live or finished site, and "
|
||||
"never imply approval. Before returning, silently compare the revised opening with three alternatives and keep the "
|
||||
"one that is most specific to this record and sounds natural when read aloud. Return ONLY strict JSON with exactly these keys: subject, message, rationale, "
|
||||
"recommended_offer, unknowns, evidence_used, follow_up. subject and message are plain text. rationale is "
|
||||
"concise. recommended_offer summarizes the exact offer. unknowns is an array of facts to confirm. "
|
||||
"evidence_used contains only supplied evidence IDs. follow_up is a short human review note, not part of the email."
|
||||
)
|
||||
prompt = json.dumps({
|
||||
"evidence": evidence,
|
||||
"current_draft": {"subject": current_subject, "message": current_message},
|
||||
"edit_instruction": instruction,
|
||||
"user_offer_or_instruction": offer,
|
||||
"tone": tone_key,
|
||||
"goal": goal_key,
|
||||
}, ensure_ascii=False)
|
||||
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
|
||||
request_payload: dict[str, Any] = {
|
||||
"model": ai_model,
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 1_000,
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
||||
}
|
||||
if runtime["provider"] != "nous":
|
||||
request_payload["response_format"] = {"type": "json_object"}
|
||||
try:
|
||||
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90, error_prefix="ai")
|
||||
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
||||
parsed = _parse_ai_object(content)
|
||||
if not str(parsed.get("subject") or "").strip() or not str(parsed.get("message") or "").strip():
|
||||
raise RuntimeError("ai_incomplete_message")
|
||||
def text_list(value: Any, limit: int = 6, size: int = 400) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
|
||||
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
|
||||
message_text = _clean_generated_message(parsed["message"], prospect.get("name"))
|
||||
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
|
||||
if mockup_url and mockup_url not in message_text:
|
||||
message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8_000]
|
||||
if mockup_url and "mockup_preview" not in used:
|
||||
used.append("mockup_preview")
|
||||
draft = {
|
||||
"subject": re.sub(r"\s+", " ", str(parsed["subject"]).strip())[:200],
|
||||
"message": message_text,
|
||||
"rationale": str(parsed.get("rationale") or "").strip()[:2_000],
|
||||
"recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2_000],
|
||||
"unknowns": text_list(parsed.get("unknowns")),
|
||||
"evidence_used": used,
|
||||
"follow_up": str(parsed.get("follow_up") or "Review the revised draft and confirm the offer before sending.").strip()[:1_000],
|
||||
"mockup_url": mockup_url,
|
||||
}
|
||||
record_log("ai", "message_revision_completed", prospect_id=prospect_id,
|
||||
details={"model": ai_model, "evidence_used": used, "subject_characters": len(draft["subject"]), "message_characters": len(message_text)})
|
||||
return draft
|
||||
except Exception as exc:
|
||||
record_log("ai", "message_revision_failed", level="error", prospect_id=prospect_id,
|
||||
details={"model": ai_model, "instruction_characters": len(instruction)}, error=str(exc)[:500])
|
||||
raise
|
||||
|
||||
|
||||
def _parse_ai_object(content: Any) -> dict[str, Any]:
|
||||
"""Parse a JSON object from models that occasionally add markdown fences."""
|
||||
text = str(content or "").strip()
|
||||
@@ -3698,6 +4044,118 @@ class Handler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
message_edit_match = re.fullmatch(r"/api/prospects/(\d+)/message/edit", urlparse(self.path).path)
|
||||
if message_edit_match:
|
||||
if not role_allows(getattr(self, "_auth_user", None), "reviewer"):
|
||||
return self.send_json(403, {"error": "permission_denied"})
|
||||
conn = None
|
||||
prospect_id = int(message_edit_match.group(1))
|
||||
organization_id = int(getattr(self, "_organization_id", 1))
|
||||
try:
|
||||
conn = db()
|
||||
row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone()
|
||||
if not row:
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
payload = self.read_json()
|
||||
instruction = str(payload.get("instruction") or payload.get("edit_instruction") or payload.get("change") or "").strip()[:3_000]
|
||||
if len(instruction) < 3:
|
||||
return self.send_json(400, {"error": "edit_instruction_required"})
|
||||
draft_value = payload.get("current_draft")
|
||||
if not isinstance(draft_value, dict):
|
||||
draft_value = payload.get("draft") if isinstance(payload.get("draft"), dict) else {}
|
||||
if "current_subject" in payload:
|
||||
raw_subject = payload.get("current_subject")
|
||||
elif "subject" in payload:
|
||||
raw_subject = payload.get("subject")
|
||||
else:
|
||||
raw_subject = draft_value.get("subject") or row["ai_subject"]
|
||||
if "current_message" in payload:
|
||||
raw_message = payload.get("current_message")
|
||||
elif "message" in payload:
|
||||
raw_message = payload.get("message")
|
||||
else:
|
||||
raw_message = draft_value.get("message") or row["ai_message"]
|
||||
current_subject = str(raw_subject or "").strip()[:300]
|
||||
current_message = str(raw_message or "").strip()[:8_000]
|
||||
if not current_subject or not current_message:
|
||||
return self.send_json(400, {"error": "current_draft_required"})
|
||||
offer = str(payload.get("offer") or payload.get("offer_or_instruction") or row["ai_offer"] or "").strip()[:2_000]
|
||||
if not offer:
|
||||
return self.send_json(400, {"error": "offer_required"})
|
||||
|
||||
# A missing field means “keep the current draft context”; an
|
||||
# explicit null/empty promotion or mockup means remove it.
|
||||
promotion_id = payload.get("promotion_id") if "promotion_id" in payload else row["ai_promotion_id"]
|
||||
promotion = None
|
||||
if promotion_id not in (None, "", 0, "0"):
|
||||
try:
|
||||
promotion_id = int(promotion_id)
|
||||
except (TypeError, ValueError):
|
||||
return self.send_json(400, {"error": "invalid_promotion_id"})
|
||||
promotion_row = conn.execute("SELECT * FROM promotions WHERE id=? AND organization_id=?", (promotion_id, organization_id)).fetchone()
|
||||
if not promotion_row:
|
||||
return self.send_json(404, {"error": "promotion_not_found"})
|
||||
if not promotion_row["active"]:
|
||||
return self.send_json(400, {"error": "promotion_inactive"})
|
||||
promotion = dict(promotion_row)
|
||||
else:
|
||||
promotion_id = None
|
||||
|
||||
if "include_mockup" in payload:
|
||||
include_value = payload.get("include_mockup")
|
||||
else:
|
||||
include_value = bool(row["ai_mockup_id"])
|
||||
include_mockup = not (include_value is False or str(include_value).strip().lower() in {"0", "false", "off", "no"})
|
||||
mockup = None
|
||||
mockup_id = payload.get("mockup_id") if "mockup_id" in payload else row["ai_mockup_id"]
|
||||
if include_mockup:
|
||||
if mockup_id not in (None, "", 0, "0"):
|
||||
try:
|
||||
mockup_id = int(mockup_id)
|
||||
except (TypeError, ValueError):
|
||||
return self.send_json(400, {"error": "invalid_mockup_id"})
|
||||
mockup_row = conn.execute("SELECT * FROM mockups WHERE id=? AND prospect_id=? AND organization_id=? AND status='published'", (mockup_id, prospect_id, organization_id)).fetchone()
|
||||
else:
|
||||
mockup_row = conn.execute("SELECT * FROM mockups WHERE prospect_id=? AND organization_id=? AND status='published' ORDER BY id DESC LIMIT 1", (prospect_id, organization_id)).fetchone()
|
||||
if mockup_row:
|
||||
mockup = dict(mockup_row)
|
||||
mockup["share_url"] = mockup_public_url(self, mockup["token"])
|
||||
mockup_id = int(mockup["id"])
|
||||
else:
|
||||
mockup_id = None
|
||||
else:
|
||||
mockup_id = None
|
||||
|
||||
draft = ai_revise_message(
|
||||
dict(row), current_subject, current_message, instruction, offer,
|
||||
tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"),
|
||||
promotion=promotion, mockup=mockup,
|
||||
)
|
||||
stamp = now()
|
||||
conn.execute(
|
||||
"UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_mockup_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?",
|
||||
(draft["subject"], draft["message"], offer, promotion_id, mockup_id, draft["rationale"],
|
||||
json.dumps(draft.get("unknowns") or [], ensure_ascii=False),
|
||||
json.dumps(draft.get("evidence_used") or [], ensure_ascii=False), draft.get("follow_up") or "",
|
||||
stamp, stamp, prospect_id, organization_id),
|
||||
)
|
||||
conn.commit()
|
||||
record_audit("prospect_message_revised", organization_id=organization_id, user_id=getattr(self, "_user_id", None),
|
||||
entity_type="prospect", entity_id=prospect_id,
|
||||
details={"promotion_id": promotion_id, "mockup_id": mockup_id, "instruction_characters": len(instruction)})
|
||||
return self.send_json(200, {**draft, "ai_checked_at": stamp, "prospect_id": prospect_id,
|
||||
"promotion_id": promotion_id, "mockup_id": mockup_id})
|
||||
except RuntimeError as exc:
|
||||
return self.send_json(503, {"error": str(exc)})
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
except sqlite3.Error as exc:
|
||||
record_log("ai", "message_revision_persist_failed", level="error", prospect_id=prospect_id,
|
||||
organization_id=organization_id, error=str(exc)[:500])
|
||||
return self.send_json(500, {"error": "message_revision_persist_failed"})
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path)
|
||||
if message_match:
|
||||
if not role_allows(getattr(self, "_auth_user", None), "reviewer"):
|
||||
|
||||
@@ -360,7 +360,12 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
status, body = self.request("POST", "/api/prospects/1/message", {"offer": "Gold Plus hosting at R119/month with a free one-page website.", "tone": "consultative", "goal": "review"})
|
||||
self.assertEqual(status, 200)
|
||||
request_payload = http_json.call_args.args[2]
|
||||
prompt = json.loads(request_payload["messages"][1]["content"])
|
||||
self.assertIn('"tone": "consultative"', request_payload["messages"][1]["content"])
|
||||
self.assertIn("client_specific_strategy", prompt["evidence"])
|
||||
self.assertIn("private planning notes", request_payload["messages"][0]["content"])
|
||||
self.assertIn("Do not mention Google", request_payload["messages"][0]["content"])
|
||||
self.assertEqual(request_payload["temperature"], 0.65)
|
||||
self.assertIn("consultative and helpful", request_payload["messages"][0]["content"])
|
||||
self.assertEqual(body["unknowns"], ["Confirm the preferred contact person"])
|
||||
self.assertEqual(body["evidence_used"], ["prospect_record", "public_maps_listing", "public_email_listing"])
|
||||
@@ -368,6 +373,17 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(saved[0]), body["unknowns"])
|
||||
self.assertEqual(json.loads(saved[1]), body["evidence_used"])
|
||||
|
||||
def test_message_style_pass_removes_stock_openers_without_rewriting_facts(self):
|
||||
text = server._clean_generated_message(
|
||||
"Good day Acme Plumbing,\n\nI hope you are well. I noticed your business listing does not show a website.\n\nKind regards",
|
||||
"Acme Plumbing",
|
||||
)
|
||||
self.assertTrue(text.startswith("Hi Acme Plumbing,"))
|
||||
self.assertNotIn("I hope you are well", text)
|
||||
self.assertNotIn("I noticed your business", text)
|
||||
self.assertIn("does not show a website", text)
|
||||
self.assertIn("Kind regards", text)
|
||||
|
||||
def test_email_draft_can_include_latest_mockup_link(self):
|
||||
conn = server.db()
|
||||
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now()))
|
||||
@@ -383,6 +399,52 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
self.assertIn("mockup_preview", body["evidence_used"])
|
||||
self.assertTrue(body["mockup_url"])
|
||||
|
||||
def test_email_draft_chat_revision_uses_current_text_and_persists(self):
|
||||
conn = server.db()
|
||||
conn.execute(
|
||||
"INSERT INTO prospects(name,domain,search_category,category,address,maps_url,website_status,emails,opportunity_score,ai_subject,ai_message,ai_offer,discovered_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("Acme Plumbing", "maps:chat", "plumbers", "Plumber", "Cape Town", "https://maps.example/acme", "no_detected_website", "acme@gmail.com", 88,
|
||||
"A practical website idea for Acme", "Good day Acme Plumbing,\n\nYour public listing does not show a dedicated website. We can help.\n\nKind regards", "Gold Plus hosting at R119/month with a free rebuild.", server.now()),
|
||||
)
|
||||
for key, value in (("AI_PROVIDER", "custom"), ("AI_BASE_URL", "https://ai.example/v1"), ("AI_MODEL", "test-model"), ("AI_API_KEY", "test-key")):
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
|
||||
conn.commit(); conn.close()
|
||||
content = json.dumps({
|
||||
"subject": "A softer website idea for Acme",
|
||||
"message": "Good day Acme Plumbing,\n\nYour public listing does not show a dedicated website. We can help with Gold Plus hosting at R119/month and a free rebuild. Would a short chat be useful?\n\nKind regards",
|
||||
"rationale": "Shortened the opening and softened the call to action as requested.",
|
||||
"recommended_offer": "Gold Plus hosting at R119/month with a free rebuild.",
|
||||
"unknowns": ["Confirm the preferred contact person"],
|
||||
"evidence_used": ["prospect_record", "public_maps_listing", "public_email_listing"],
|
||||
"follow_up": "Check the wording and offer before sending.",
|
||||
})
|
||||
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}) as http_json:
|
||||
status, body = self.request("POST", "/api/prospects/1/message/edit", {
|
||||
"instruction": "Make it warmer, shorter, and use a softer call to action.",
|
||||
"current_subject": "A practical website idea for Acme",
|
||||
"current_message": "Good day Acme Plumbing,\n\nYour public listing does not show a dedicated website. We can help.\n\nKind regards",
|
||||
"offer": "Gold Plus hosting at R119/month with a free rebuild.",
|
||||
"tone": "warm",
|
||||
"goal": "conversation",
|
||||
"include_mockup": False,
|
||||
})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["subject"], "A softer website idea for Acme")
|
||||
self.assertIn("softened", body["rationale"])
|
||||
request_payload = http_json.call_args.args[2]
|
||||
prompt = json.loads(request_payload["messages"][1]["content"])
|
||||
self.assertEqual(prompt["current_draft"]["subject"], "A practical website idea for Acme")
|
||||
self.assertIn("Make it warmer", prompt["edit_instruction"])
|
||||
self.assertEqual(request_payload["max_tokens"], 1000)
|
||||
self.assertIn("cannot add facts", request_payload["messages"][0]["content"])
|
||||
saved = server.db().execute("SELECT ai_subject,ai_message,ai_offer FROM prospects WHERE id=1").fetchone()
|
||||
self.assertEqual(saved[0], body["subject"])
|
||||
self.assertEqual(saved[1], body["message"])
|
||||
self.assertEqual(saved[2], "Gold Plus hosting at R119/month with a free rebuild.")
|
||||
status, logs = self.request("GET", "/api/logs?kind=ai")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(any(item["event"] == "message_revision_completed" for item in logs["items"]))
|
||||
|
||||
def test_logs_capture_api_actions_and_redact_credentials(self):
|
||||
server.record_log("ai", "test_ai_call", details={"api_key": "do-not-store", "model": "test", "prompt": "hello"})
|
||||
status, body = self.request("POST", "/api/logs", {"action": "ui_test", "details": {"button": "test"}})
|
||||
|
||||
+87
-3
@@ -71,11 +71,11 @@ async function load(){
|
||||
}
|
||||
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
|
||||
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
|
||||
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
|
||||
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
|
||||
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
|
||||
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
|
||||
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')}
|
||||
function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, your company knowledge, and any selected promotion.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label><label for="messagePromotion">Promotion<select id="messagePromotion"><option value="">No promotion</option>${promotionOptions()}</select><small class="field-help">Only active promotions appear here.</small></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate tailored draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
|
||||
function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts, your company knowledge, and any selected promotion. You can edit every word before copying it.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label><label for="messagePromotion">Promotion<select id="messagePromotion"><option value="">No promotion</option>${promotionOptions()}</select><small class="field-help">Only active promotions appear here.</small></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate client-specific draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
|
||||
async function openMessage(id){ensureMessageDialog(true);selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;const score=effectiveScore(selected);$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`<div class="message-context-title"><span class="score ${scoreClass(score)}">${score}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';if($('messagePromotion'))$('messagePromotion').value=selected.ai_promotion_id&&[...$('messagePromotion').options].some(option=>option.value===String(selected.ai_promotion_id))?String(selected.ai_promotion_id):'';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal();loadMessageMockupOption(id)}
|
||||
function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'}
|
||||
function reviewStatusTabs(p){const options=[['pending','In review'],['approved','Accepted'],['rejected','Rejected'],['contacted','Contacted']];return `<div class="drawer-section review-section"><div class="section-heading"><h3>Review status</h3><span class="muted">Saved to this prospect</span></div><div class="drawer-review-tabs">${options.map(([value,label])=>`<button type="button" class="drawer-review-tab ${p.review_status===value?'active':''}" data-drawer-review="${p.id}" data-status="${value}">${label}</button>`).join('')}</div></div>`}
|
||||
@@ -105,7 +105,7 @@ document.addEventListener('submit',async e=>{
|
||||
if(e.target.id==='teachAiForm'){e.preventDefault();const m=$('teachAiMessage');m.textContent='Saving…';m.className='notice';try{const result=await api('/api/settings',{method:'POST',body:JSON.stringify({company_profile:{website_url:$('companyWebsiteUrl').value,name:$('companyName').value,description:$('companyDescription').value,services:$('companyServices').value,packages:$('companyPackages').value,tone:$('companyTone').value,notes:$('companyNotes').value}})});settings.company_profile=result.company_profile||settings.company_profile;m.textContent='Company knowledge saved.';m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}}
|
||||
if(e.target.id==='promotionForm'){e.preventDefault();const m=$('promotionMessage');m.textContent='Saving…';m.className='notice';try{await api('/api/promotions',{method:'POST',body:JSON.stringify({name:$('promotionName').value,description:$('promotionDescription').value,terms:$('promotionTerms').value,starts_at:$('promotionStarts').value,ends_at:$('promotionEnds').value,active:$('promotionActive').checked})});route()}catch(x){m.textContent=x.message;m.className='notice error'}}
|
||||
});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='Comparing the prospect facts with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Read it aloud, edit anything you want, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Generate client-specific draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}});
|
||||
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
|
||||
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
|
||||
@@ -247,3 +247,87 @@ document.addEventListener('click',e=>{
|
||||
if(!edit)return;
|
||||
e.preventDefault();e.stopPropagation();openMockupEditor(edit);
|
||||
},true);
|
||||
|
||||
/* AI email draft editor -------------------------------------------------- */
|
||||
/*
|
||||
* The draft dialog deliberately stays a method="dialog" form. The chat is
|
||||
* therefore a button-driven composer rather than a nested form (nested forms
|
||||
* make the browser close the dialog instead of applying the requested edit).
|
||||
* Each request sends the live subject/body fields, so manual edits and prior
|
||||
* chat revisions are both preserved as the next working draft.
|
||||
*/
|
||||
const _ensureMessageDialogWithRevisionChat=ensureMessageDialog;
|
||||
ensureMessageDialog=function(force=false){
|
||||
_ensureMessageDialogWithRevisionChat(force);
|
||||
const output=document.querySelector('#messageForm .draft-output');
|
||||
if(!output||$('messageRevisionPanel'))return;
|
||||
output.insertAdjacentHTML('afterend',`<section id="messageRevisionPanel" class="message-revision-panel"><div class="message-revision-head"><div><span class="eyebrow">DRAFT CHAT</span><h3>Ask the AI to refine it</h3><p>Describe a change and the current subject and email will be revised in place.</p></div><span class="message-chat-badge">Review before sending</span></div><div id="messageChatMessages" class="message-chat-messages" role="log" aria-live="polite"></div><div class="message-revision-composer"><label for="messageRevisionInput">Requested change</label><textarea id="messageRevisionInput" rows="3" maxlength="3000" placeholder="Make it shorter and less salesy, keep the R119/month offer, and use a softer call to action."></textarea><div id="messageRevisionStatus" class="notice">The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.</div><button type="button" class="primary" id="messageRevisionApply">Apply change <span>→</span></button></div></section>`);
|
||||
};
|
||||
|
||||
function messageChatAdd(kind,text){
|
||||
const messages=$('messageChatMessages');
|
||||
if(!messages)return;
|
||||
const bubble=document.createElement('div');
|
||||
bubble.className=`message-chat-message ${kind||'assistant'}`;
|
||||
bubble.textContent=String(text||'');
|
||||
messages.append(bubble);
|
||||
messages.scrollTop=messages.scrollHeight;
|
||||
}
|
||||
|
||||
function rememberMessageDraft(d){
|
||||
if(!selected||!d)return;
|
||||
const promotionValue=$('messagePromotion')?.value||'';
|
||||
const mockupCheckbox=$('includeMockup');
|
||||
const mockupValue=d.mockup_id??mockupCheckbox?.dataset?.mockupId??selected.ai_mockup_id??null;
|
||||
selected={...selected,
|
||||
ai_subject:d.subject||'',ai_message:d.message||'',ai_offer:$('offerInput')?.value||selected.ai_offer||'',
|
||||
ai_promotion_id:d.promotion_id??(promotionValue?Number(promotionValue):null),
|
||||
ai_mockup_id:d.mockup_url?Number(mockupValue||0)||null:null,
|
||||
ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),
|
||||
ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||'',ai_checked_at:d.ai_checked_at||selected.ai_checked_at||''
|
||||
};
|
||||
const index=prospects.findIndex(p=>String(p.id)===String(selected.id));
|
||||
if(index>=0)prospects[index]=selected;
|
||||
}
|
||||
|
||||
const _openMessageWithRevisionChat=openMessage;
|
||||
openMessage=async function(id){
|
||||
await _openMessageWithRevisionChat(id);
|
||||
const messages=$('messageChatMessages');
|
||||
if(messages){messages.innerHTML='';messageChatAdd('assistant','I can revise the subject and email while keeping the public evidence, exact offer, promotion terms, and selected preview link intact. What would you like changed?');}
|
||||
const input=$('messageRevisionInput');if(input)input.value='';
|
||||
const status=$('messageRevisionStatus');if(status){status.textContent='The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.';status.className='notice';}
|
||||
};
|
||||
|
||||
async function reviseMessageDraft(){
|
||||
if(!selected)return;
|
||||
const input=$('messageRevisionInput'),button=$('messageRevisionApply'),status=$('messageRevisionStatus');
|
||||
const instruction=String(input?.value||'').trim();
|
||||
const subject=String($('subjectOutput')?.value||'').trim();
|
||||
const body=String($('bodyOutput')?.value||'').trim();
|
||||
if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}input?.focus();return;}
|
||||
if(!subject||!body){if(status){status.textContent='Generate a draft first, then ask the AI to revise it.';status.className='notice error'}return;}
|
||||
if(button){button.disabled=true;button.textContent='Applying…';}
|
||||
if(status){status.textContent='Applying your request to the current draft…';status.className='notice';}
|
||||
messageChatAdd('user',instruction);
|
||||
try{
|
||||
const checkbox=$('includeMockup');
|
||||
const d=await api(`/api/prospects/${selected.id}/message/edit`,{method:'POST',body:JSON.stringify({
|
||||
instruction,current_subject:subject,current_message:body,offer:$('offerInput')?.value||'',
|
||||
tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',
|
||||
promotion_id:$('messagePromotion')?.value||null,include_mockup:checkbox?.checked!==false,mockup_id:checkbox?.dataset?.mockupId||null
|
||||
})});
|
||||
$('subjectOutput').value=d.subject||'';$('bodyOutput').value=d.message||'';rememberMessageDraft(d);showDraftNotes(d);
|
||||
messageChatAdd('assistant',d.rationale?`Updated the draft: ${d.rationale}`:'Updated the draft. Review the new wording and ask for another change if needed.');
|
||||
if(status){status.textContent='Draft revised. Verify the facts, then copy it to your mail client.';status.className='notice ok';}
|
||||
if(input)input.value='';
|
||||
}catch(error){messageChatAdd('system',`I could not revise the draft: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error';}}
|
||||
finally{if(button){button.disabled=false;button.innerHTML='Apply change <span>→</span>';}input?.focus();}
|
||||
}
|
||||
|
||||
document.addEventListener('click',e=>{if(e.target.id==='messageRevisionApply')reviseMessageDraft()});
|
||||
document.addEventListener('keydown',e=>{if(e.target.id==='messageRevisionInput'&&e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();$('messageRevisionApply')?.click()}});
|
||||
|
||||
/* The first call above happens before this enhancement is defined. Run the
|
||||
* non-destructive wrapper once so the panel exists before the first click. */
|
||||
ensureMessageDialog(false);
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v5"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v5"></script></body></html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v7"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v7"></script></body></html>
|
||||
|
||||
@@ -172,3 +172,14 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
|
||||
.mockup-chat-form{display:grid;gap:8px;padding:14px 16px 12px;border-top:1px solid #edf0f5}.mockup-chat-form label{display:grid;gap:5px;color:#627087;font-size:10px;font-weight:850}.mockup-chat-form select{margin:0;height:34px;font-size:11px}.mockup-chat-form textarea{margin:0;min-height:106px;resize:vertical;font-size:12px;line-height:1.5}.mockup-chat-form .notice{margin:0;font-size:10px;line-height:1.4}.mockup-chat-form button{width:100%;min-height:42px}.mockup-editor-note{display:grid;gap:4px;padding:12px 16px 16px;background:#fafbff;color:#8b96a8;font-size:10px;line-height:1.45}.mockup-editor-note b{color:#5c6a80;font-size:10px}.mockup-editor-note span{display:block}
|
||||
@media(max-width:900px){.mockup-editor-dialog{width:min(760px,calc(100vw - 20px));height:min(900px,calc(100vh - 20px))}.mockup-editor-layout{grid-template-columns:1fr;grid-template-rows:minmax(310px,1fr) auto}.mockup-editor-preview iframe{min-height:300px}.mockup-editor-chat{grid-template-rows:auto minmax(95px,1fr) auto auto;border-left:0;border-top:1px solid #dfe4ed;max-height:430px}.mockup-chat-messages{min-height:95px;max-height:160px}}
|
||||
@media(max-width:560px){.mockup-editor-dialog{width:100vw;height:100vh;border-radius:0}.mockup-editor-head{padding:15px 16px}.mockup-editor-head h2{font-size:17px}.mockup-editor-head p{max-width:230px}.mockup-editor-head-actions .pill{display:none}.mockup-editor-layout{grid-template-rows:minmax(260px,1fr) auto}.mockup-editor-preview-bar{padding:9px 12px}.mockup-editor-chat{max-height:430px}.mockup-chat-form{padding:11px 12px}.mockup-editor-note{padding:10px 12px 13px}}
|
||||
|
||||
/* AI email draft revision chat */
|
||||
.message-revision-panel{display:grid;gap:0;margin-top:4px;border:1px solid #e1e5ef;border-radius:13px;overflow:hidden;background:#fff}
|
||||
.message-revision-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:15px 16px 12px;border-bottom:1px solid #edf0f5;background:linear-gradient(135deg,#fbfaff,#fff)}
|
||||
.message-revision-head h3{margin:4px 0 3px;color:#2e3b53;font-size:15px;letter-spacing:-.02em}.message-revision-head p{margin:0;color:#8a96a8;font-size:10px;line-height:1.45}
|
||||
.message-chat-badge{padding:5px 8px;border-radius:999px;background:#eef8f5;color:#17876b;font-size:9px;font-weight:900;white-space:nowrap}
|
||||
.message-chat-messages{display:grid;align-content:start;gap:8px;max-height:154px;overflow:auto;padding:12px 14px;background:linear-gradient(180deg,#fbfcff,#fff)}
|
||||
.message-chat-message{max-width:92%;padding:9px 10px;border-radius:10px;font-size:11px;line-height:1.45;white-space:pre-wrap}.message-chat-message.assistant{justify-self:start;border:1px solid #e1e5ef;background:#f5f6fb;color:#59677e}.message-chat-message.user{justify-self:end;background:#635bff;color:#fff;box-shadow:0 6px 14px #635bff2b}.message-chat-message.system{justify-self:start;border:1px solid #f1c7cd;background:#fff5f6;color:#a33e4d}
|
||||
.message-revision-composer{display:grid;gap:7px;padding:12px 14px 14px;border-top:1px solid #edf0f5;background:#fff}.message-revision-composer label{color:#627087;font-size:10px;font-weight:850}.message-revision-composer textarea{min-height:74px;margin:0;resize:vertical;font-size:12px;line-height:1.5}.message-revision-composer .notice{margin:0;font-size:10px;line-height:1.4}.message-revision-composer button{width:100%;min-height:40px}
|
||||
.message-dialog{max-height:calc(100vh - 24px);overflow:auto}.message-dialog form{min-width:0}.message-dialog .draft-output{min-width:0}
|
||||
@media(max-width:560px){.message-revision-head{display:block}.message-chat-badge{display:inline-block;margin-top:9px}.message-chat-messages{max-height:130px}}
|
||||
|
||||
Reference in New Issue
Block a user