diff --git a/backend/internal/library/llmjson/sanitize.go b/backend/internal/library/llmjson/sanitize.go index 9ad9440..66b055d 100644 --- a/backend/internal/library/llmjson/sanitize.go +++ b/backend/internal/library/llmjson/sanitize.go @@ -24,17 +24,71 @@ func Unmarshal(payload []byte, v any) error { return err } -// Sanitize removes ellipsis placeholders and normalizes stray commas that sit -// outside of string literals. It returns nil when nothing changed so callers -// can avoid a redundant re-parse. String/URL contents are never altered. +// Sanitize removes ellipsis placeholders, escapes unescaped control characters +// inside JSON string literals, and normalizes stray commas that sit outside of +// string literals. It returns nil when nothing changed so callers can avoid a +// redundant re-parse. String meaning is preserved; only invalid JSON escapes +// are added. func Sanitize(in []byte) []byte { - normalized := normalizeCommas(stripEllipsis(string(in))) + normalized := normalizeCommas(stripEllipsis(escapeControlCharsInStrings(string(in)))) if normalized == string(in) { return nil } return []byte(normalized) } +func escapeControlCharsInStrings(s string) string { + var b strings.Builder + b.Grow(len(s) + 16) + inString := false + escaped := false + for i := 0; i < len(s); i++ { + ch := s[i] + if inString { + if escaped { + escaped = false + b.WriteByte(ch) + continue + } + switch ch { + case '\\': + escaped = true + b.WriteByte(ch) + case '"': + inString = false + b.WriteByte(ch) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if ch < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hexDigit(ch >> 4)) + b.WriteByte(hexDigit(ch & 0x0f)) + } else { + b.WriteByte(ch) + } + } + continue + } + if ch == '"' { + inString = true + } + b.WriteByte(ch) + } + return b.String() +} + +func hexDigit(v byte) byte { + if v < 10 { + return '0' + v + } + return 'a' + (v - 10) +} + func stripEllipsis(s string) string { var b strings.Builder b.Grow(len(s)) diff --git a/backend/internal/library/llmjson/sanitize_test.go b/backend/internal/library/llmjson/sanitize_test.go index 9b2d566..567c111 100644 --- a/backend/internal/library/llmjson/sanitize_test.go +++ b/backend/internal/library/llmjson/sanitize_test.go @@ -35,6 +35,28 @@ func TestSanitizeReturnsNilWhenValid(t *testing.T) { } } +func TestSanitizeEscapesNewlinesInsideStrings(t *testing.T) { + raw := []byte(`{"examples":"第一行 +第二行","identity":"觀察者"}`) + cleaned := Sanitize(raw) + if cleaned == nil { + t.Fatal("expected sanitized output") + } + var out struct { + Examples string `json:"examples"` + Identity string `json:"identity"` + } + if err := Unmarshal(raw, &out); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if out.Examples != "第一行\n第二行" { + t.Fatalf("examples = %q", out.Examples) + } + if out.Identity != "觀察者" { + t.Fatalf("identity = %q", out.Identity) + } +} + func TestSanitizeKeepsDotsInsideStrings(t *testing.T) { raw := []byte(`{"note":"see ... and https://a.b/c", "list":[1, ...]}`) cleaned := Sanitize(raw) diff --git a/backend/internal/library/prompt/files/style8d.schema.md b/backend/internal/library/prompt/files/style8d.schema.md index a38ab13..387e792 100644 --- a/backend/internal/library/prompt/files/style8d.schema.md +++ b/backend/internal/library/prompt/files/style8d.schema.md @@ -1,3 +1,2 @@ - -只回傳以下 JSON 結構,不要 markdown: +只回傳以下 JSON 結構,不要 markdown。所有字串值必須是單行 JSON string;需要換行時用 `\n`,禁止在引號內直接換行。 {"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}} diff --git a/backend/internal/library/prompt/files/style8d.system.md b/backend/internal/library/prompt/files/style8d.system.md index 8ff14f9..e0ac2ae 100644 --- a/backend/internal/library/prompt/files/style8d.system.md +++ b/backend/internal/library/prompt/files/style8d.system.md @@ -17,5 +17,5 @@ personaDraft 必須產出可執行的寫作規則,不是抽象形容詞: - contentPatterns:不同內容型態的鬆結構,例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴;不要變成固定模板。 - knowledgeTranslation:如何把外部資料、醫療知識、產品知識轉成此人設會講的話;哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。 - ctaStyle:收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。 -- examples:抽象仿寫範例,可展示語氣與換行,但不可逐字複製原文。 +- examples:抽象仿寫範例,可展示語氣與換行,但不可逐字複製原文;JSON 內若要換行請用 `\n`,不可在字串值內直接斷行。 - avoid:不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。 diff --git a/backend/internal/library/style8d/analyze.go b/backend/internal/library/style8d/analyze.go index 9d75a6e..a15bc5e 100644 --- a/backend/internal/library/style8d/analyze.go +++ b/backend/internal/library/style8d/analyze.go @@ -8,6 +8,8 @@ import ( "regexp" "strings" "time" + + "haixun-backend/internal/library/llmjson" ) type Post struct { @@ -290,13 +292,13 @@ func ParseLLMOutput(raw string) (LLMOutput, error) { return LLMOutput{}, err } var root map[string]json.RawMessage - if err := json.Unmarshal(payload, &root); err != nil { + if err := llmjson.Unmarshal(payload, &root); err != nil { return LLMOutput{}, fmt.Errorf("parse style8d json: %w", err) } for _, key := range []string{"analysis", "style8d", "style8D", "result", "data", "output"} { if nested, ok := root[key]; ok { var merged map[string]json.RawMessage - if err := json.Unmarshal(nested, &merged); err == nil { + if err := llmjson.Unmarshal(nested, &merged); err == nil { for k, v := range merged { if _, exists := root[k]; !exists { root[k] = v @@ -473,11 +475,11 @@ func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension { func decodeDimension(raw json.RawMessage) Dimension { var asString string - if err := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { + if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { return Dimension{Summary: strings.TrimSpace(asString)} } var obj map[string]json.RawMessage - if err := json.Unmarshal(raw, &obj); err != nil { + if err := llmjson.Unmarshal(raw, &obj); err != nil { return Dimension{} } summary := firstString(obj, "summary", "description", "analysis", "conclusion") @@ -492,11 +494,11 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft { continue } var asString string - if err := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { + if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { return PersonaDraft{Identity: strings.TrimSpace(asString)} } var obj map[string]json.RawMessage - if err := json.Unmarshal(raw, &obj); err != nil { + if err := llmjson.Unmarshal(raw, &obj); err != nil { continue } return PersonaDraft{ @@ -524,7 +526,7 @@ func firstString(obj map[string]json.RawMessage, keys ...string) string { continue } var value string - if err := json.Unmarshal(raw, &value); err == nil { + if err := llmjson.Unmarshal(raw, &value); err == nil { value = strings.TrimSpace(value) if value != "" { return value @@ -541,7 +543,7 @@ func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string { continue } var values []string - if err := json.Unmarshal(raw, &values); err == nil { + if err := llmjson.Unmarshal(raw, &values); err == nil { out := make([]string, 0, len(values)) for _, item := range values { item = strings.TrimSpace(item) @@ -554,7 +556,7 @@ func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string { } } var single string - if err := json.Unmarshal(raw, &single); err == nil { + if err := llmjson.Unmarshal(raw, &single); err == nil { single = strings.TrimSpace(single) if single != "" { return []string{single} diff --git a/backend/internal/library/style8d/analyze_test.go b/backend/internal/library/style8d/analyze_test.go index d8ee8b0..978d605 100644 --- a/backend/internal/library/style8d/analyze_test.go +++ b/backend/internal/library/style8d/analyze_test.go @@ -5,6 +5,18 @@ import ( "testing" ) +func TestParseLLMOutputWithLiteralNewlinesInStrings(t *testing.T) { + raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","languageFingerprint":"有時候","rhythm":"短段落","punctuation":"逗號","contentPatterns":"心情文","knowledgeTranslation":"先講情境","ctaStyle":"留言","examples":"有時候真的會累 +後來就習慣了","avoid":"硬銷"}}` + out, err := ParseLLMOutput(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.PersonaDraft.Examples, "後來就習慣了") { + t.Fatalf("examples=%q", out.PersonaDraft.Examples) + } +} + func TestParseLLMOutput(t *testing.T) { raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}` out, err := ParseLLMOutput(raw) diff --git a/backend/web/vite.config.ts b/backend/web/vite.config.ts index 773fa1e..f6ab990 100644 --- a/backend/web/vite.config.ts +++ b/backend/web/vite.config.ts @@ -6,8 +6,12 @@ export default defineConfig({ server: { host: "0.0.0.0", port: 5173, - // 支援透過 https://threads-tool.30cm.net 存取開發伺服器(需反向代理轉發) - allowedHosts: ["threads-tool.30cm.net", "localhost"], + // 支援透過 nginx 網域存取開發伺服器(需反向代理轉發) + allowedHosts: [ + "threads-tool.30cm.net", + "threads-tool-dev.30cm.net", + "localhost", + ], // 如果反向代理在 https 並轉發 HMR websocket,請視代理設定調整 hmr // hmr: { // host: "threads-tool.30cm.net", diff --git a/scripts/dev-all.sh b/scripts/dev-all.sh index 553dcd4..bc4a970 100755 --- a/scripts/dev-all.sh +++ b/scripts/dev-all.sh @@ -145,6 +145,18 @@ raise SystemExit('no free Vite port found in 5173..5199') PY } +resolve_web_port() { + local requested="${WEB_PORT:-}" + if [[ -n "$requested" && "$requested" -ge 1024 ]]; then + echo "$requested" + return + fi + if [[ -n "$requested" ]]; then + echo "[dev-all] WEB_PORT=$requested is for prod/nginx; using a free dev port instead" >&2 + fi + pick_web_port +} + load_env cd "$BACKEND_DIR" @@ -198,7 +210,7 @@ start "node-worker:style-8d" env \ npm run style-8d cd "$WEB_DIR" -WEB_PORT="${WEB_PORT:-$(pick_web_port)}" +WEB_PORT="$(resolve_web_port)" start "web" npm run dev -- --host 0.0.0.0 --port "$WEB_PORT" --strictPort wait_for_http "web" "http://127.0.0.1:$WEB_PORT/"