ltcai 0.1.9 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +174 -305
  2. package/docs/CHANGELOG.md +307 -0
  3. package/docs/architecture.md +121 -0
  4. package/docs/mcp-tools.md +116 -0
  5. package/docs/privacy.md +74 -0
  6. package/docs/public-deploy.md +137 -0
  7. package/docs/security-model.md +121 -0
  8. package/knowledge_graph.py +123 -15
  9. package/llm_router.py +100 -28
  10. package/ltcai_cli.py +138 -5
  11. package/package.json +14 -2
  12. package/server.py +1756 -329
  13. package/skills/SKILL_TEMPLATE.md +61 -29
  14. package/skills/code_review/SKILL.md +28 -0
  15. package/skills/code_review/examples.md +59 -0
  16. package/skills/code_review/risk.json +9 -0
  17. package/skills/code_review/schema.json +65 -0
  18. package/skills/data_analysis/SKILL.md +28 -0
  19. package/skills/data_analysis/examples.md +62 -0
  20. package/skills/data_analysis/risk.json +9 -0
  21. package/skills/data_analysis/schema.json +61 -0
  22. package/skills/file_edit/SKILL.md +33 -0
  23. package/skills/file_edit/examples.md +45 -0
  24. package/skills/file_edit/risk.json +9 -0
  25. package/skills/file_edit/schema.json +60 -0
  26. package/skills/summarize_document/SKILL.md +68 -0
  27. package/skills/summarize_document/examples.md +65 -0
  28. package/skills/summarize_document/risk.json +9 -0
  29. package/skills/summarize_document/schema.json +71 -0
  30. package/skills/web_search/SKILL.md +28 -0
  31. package/skills/web_search/examples.md +61 -0
  32. package/skills/web_search/risk.json +9 -0
  33. package/skills/web_search/schema.json +62 -0
  34. package/static/account.html +53 -51
  35. package/static/admin.html +50 -46
  36. package/static/chat.html +124 -96
  37. package/static/graph.html +1231 -337
  38. package/static/manifest.json +2 -2
  39. package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
  41. package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
  42. package/tests/unit/test_tools.py +194 -1
  43. package/tools.py +264 -4
package/static/chat.html CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  <!-- PWA -->
10
10
  <link rel="manifest" href="/manifest.json">
11
- <meta name="theme-color" content="#2d5a3d">
11
+ <meta name="theme-color" content="#282a36">
12
12
  <meta name="mobile-web-app-capable" content="yes">
13
13
  <meta name="apple-mobile-web-app-capable" content="yes">
14
14
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -2724,30 +2724,31 @@
2724
2724
  }
2725
2725
  </style>
2726
2726
  <style>
2727
- /* Chat workspace refresh. Scoped to the signed-in app so the login page keeps its current look. */
2727
+ /* Obsidian-inspired workspace refresh. Scoped to the signed-in app. */
2728
2728
  .app-layout {
2729
- --bg: #111312;
2730
- --surface: #181b19;
2731
- --surface-2: #20241f;
2732
- --surface-3: #282e27;
2733
- --accent: #41d39b;
2734
- --accent-2: #d8a642;
2735
- --accent-3: #d6c28f;
2736
- --accent-pink: #d98686;
2737
- --accent-soft: rgba(65, 211, 155, 0.11);
2738
- --text: #f3f1e8;
2739
- --muted: #b8b5a9;
2740
- --faint: #7f8278;
2741
- --border: rgba(243, 241, 232, 0.11);
2742
- --border-strong: rgba(243, 241, 232, 0.2);
2743
- --shadow: 0 22px 60px rgba(0, 0, 0, 0.34);
2744
- --shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.24);
2745
- --glow-green: 0 0 28px rgba(65, 211, 155, 0.13);
2729
+ --bg: #282a36;
2730
+ --surface: #222431;
2731
+ --surface-2: #2f3142;
2732
+ --surface-3: #3a3d52;
2733
+ --accent: #a77cff;
2734
+ --accent-2: #f1c86d;
2735
+ --accent-3: #7db7ff;
2736
+ --accent-pink: #ff7db3;
2737
+ --accent-soft: rgba(167, 124, 255, 0.13);
2738
+ --text: #f7f7f2;
2739
+ --muted: #c4c8d8;
2740
+ --faint: #8d93ab;
2741
+ --border: rgba(218, 225, 255, 0.13);
2742
+ --border-strong: rgba(218, 225, 255, 0.23);
2743
+ --shadow: 0 22px 64px rgba(5, 7, 12, 0.34);
2744
+ --shadow-sm: 0 8px 24px rgba(5, 7, 12, 0.24);
2745
+ --glow-green: 0 0 30px rgba(167, 124, 255, 0.14);
2746
2746
  position: relative;
2747
2747
  isolation: isolate;
2748
2748
  background:
2749
+ radial-gradient(circle at 48% 42%, rgba(125,183,255,0.09), transparent 34%),
2749
2750
  linear-gradient(180deg, rgba(255,255,255,0.018), transparent 34%),
2750
- linear-gradient(135deg, #141715 0%, #101210 52%, #171611 100%);
2751
+ linear-gradient(135deg, #292b38 0%, #242632 52%, #2f3040 100%);
2751
2752
  }
2752
2753
 
2753
2754
  .app-layout::before {
@@ -2757,21 +2758,35 @@
2757
2758
  z-index: -1;
2758
2759
  pointer-events: none;
2759
2760
  background:
2760
- linear-gradient(rgba(243,241,232,0.035) 1px, transparent 1px),
2761
- linear-gradient(90deg, rgba(243,241,232,0.025) 1px, transparent 1px);
2762
- background-size: 44px 44px;
2763
- mask-image: linear-gradient(180deg, rgba(0,0,0,0.42), rgba(0,0,0,0.08));
2761
+ radial-gradient(circle, rgba(247,247,242,0.7) 1px, transparent 1.8px),
2762
+ linear-gradient(rgba(157,177,255,0.13) 1px, transparent 1px),
2763
+ linear-gradient(90deg, rgba(157,177,255,0.09) 1px, transparent 1px);
2764
+ background-size: 76px 76px, 48px 48px, 48px 48px;
2765
+ background-position: 18px 22px, 0 0, 0 0;
2766
+ mask-image: linear-gradient(180deg, rgba(0,0,0,0.45), rgba(0,0,0,0.09));
2767
+ }
2768
+
2769
+ .app-layout::after {
2770
+ content: '';
2771
+ position: absolute;
2772
+ inset: 0;
2773
+ z-index: -1;
2774
+ pointer-events: none;
2775
+ background:
2776
+ linear-gradient(116deg, transparent 0 42%, rgba(125,183,255,0.08) 42.15%, transparent 42.35% 100%),
2777
+ linear-gradient(28deg, transparent 0 58%, rgba(167,124,255,0.08) 58.15%, transparent 58.35% 100%);
2778
+ opacity: 0.72;
2764
2779
  }
2765
2780
 
2766
2781
  .app-layout .sidebar {
2767
- background: rgba(20, 23, 21, 0.96);
2768
- border-right-color: rgba(243, 241, 232, 0.1);
2782
+ background: rgba(30, 32, 43, 0.94);
2783
+ border-right-color: rgba(218,225,255,0.13);
2769
2784
  box-shadow: inset -1px 0 0 rgba(255,255,255,0.025);
2770
2785
  }
2771
2786
 
2772
2787
  @media (max-width: 768px) {
2773
2788
  .app-layout .sidebar {
2774
- background: #141715;
2789
+ background: #222431;
2775
2790
  backdrop-filter: none;
2776
2791
  -webkit-backdrop-filter: none;
2777
2792
  }
@@ -2785,9 +2800,9 @@
2785
2800
 
2786
2801
  .app-layout .logo-box,
2787
2802
  .app-layout .user-avatar {
2788
- background: linear-gradient(135deg, #41d39b 0%, #d8a642 100%);
2789
- color: #101210;
2790
- box-shadow: 0 10px 24px rgba(0,0,0,0.26);
2803
+ background: radial-gradient(circle at 35% 30%, #ffffff 0 16%, #a77cff 17% 62%, #5b6cff 100%);
2804
+ color: #171926;
2805
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.18), 0 10px 24px rgba(0,0,0,0.28), 0 0 26px rgba(167,124,255,0.18);
2791
2806
  }
2792
2807
 
2793
2808
  .app-layout .brand-title {
@@ -2799,20 +2814,20 @@
2799
2814
 
2800
2815
  .app-layout .history-item:hover,
2801
2816
  .app-layout .history-item.active {
2802
- background: rgba(243,241,232,0.055);
2817
+ background: rgba(167,124,255,0.10);
2803
2818
  border-left-color: var(--accent);
2804
2819
  }
2805
2820
 
2806
2821
  .app-layout .history-item.active {
2807
- border-color: rgba(65,211,155,0.22);
2822
+ border-color: rgba(167,124,255,0.24);
2808
2823
  }
2809
2824
 
2810
2825
  .app-layout .new-chat-btn,
2811
2826
  .app-layout .status-btn,
2812
2827
  .app-layout .setup-wizard-sidebar-btn,
2813
2828
  .app-layout .admin-btn {
2814
- background: rgba(243,241,232,0.045);
2815
- border-color: rgba(243,241,232,0.1);
2829
+ background: rgba(247,247,242,0.045);
2830
+ border-color: rgba(218,225,255,0.12);
2816
2831
  color: var(--text);
2817
2832
  box-shadow: none;
2818
2833
  }
@@ -2821,8 +2836,8 @@
2821
2836
  .app-layout .status-btn:hover,
2822
2837
  .app-layout .setup-wizard-sidebar-btn:hover,
2823
2838
  .app-layout .admin-btn:hover {
2824
- background: rgba(65,211,155,0.1);
2825
- border-color: rgba(65,211,155,0.28);
2839
+ background: rgba(167,124,255,0.12);
2840
+ border-color: rgba(167,124,255,0.3);
2826
2841
  color: var(--accent);
2827
2842
  }
2828
2843
 
@@ -2831,15 +2846,15 @@
2831
2846
  }
2832
2847
 
2833
2848
  .app-layout .chat-header {
2834
- background: rgba(18, 20, 18, 0.88);
2835
- border-bottom-color: rgba(243,241,232,0.09);
2849
+ background: rgba(33, 35, 48, 0.82);
2850
+ border-bottom-color: rgba(218,225,255,0.12);
2836
2851
  box-shadow: 0 1px 0 rgba(255,255,255,0.025);
2837
2852
  }
2838
2853
 
2839
2854
  .app-layout .model-badge,
2840
2855
  .app-layout .status-pill {
2841
- background: rgba(243,241,232,0.045);
2842
- border-color: rgba(243,241,232,0.1);
2856
+ background: rgba(247,247,242,0.045);
2857
+ border-color: rgba(218,225,255,0.13);
2843
2858
  color: var(--muted);
2844
2859
  }
2845
2860
 
@@ -2848,44 +2863,45 @@
2848
2863
  }
2849
2864
 
2850
2865
  .app-layout .status-pill.vpc-ready {
2851
- background: rgba(65,211,155,0.09);
2852
- border-color: rgba(65,211,155,0.24);
2866
+ background: rgba(32,184,170,0.11);
2867
+ border-color: rgba(32,184,170,0.28);
2868
+ color: #7ee3d7;
2853
2869
  }
2854
2870
 
2855
2871
  .app-layout .status-dot {
2856
- background: var(--accent);
2857
- box-shadow: 0 0 8px rgba(65,211,155,0.55);
2872
+ background: #f7f7f2;
2873
+ box-shadow: 0 0 8px rgba(247,247,242,0.7), 0 0 18px rgba(167,124,255,0.36);
2858
2874
  }
2859
2875
 
2860
2876
  .app-layout .ops-card {
2861
- background: rgba(24, 27, 25, 0.88);
2862
- border-color: rgba(243,241,232,0.11);
2877
+ background: rgba(34, 36, 49, 0.82);
2878
+ border-color: rgba(218,225,255,0.14);
2863
2879
  box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255,255,255,0.035);
2864
2880
  }
2865
2881
 
2866
2882
  .app-layout .ops-card.primary {
2867
- background: linear-gradient(135deg, rgba(65,211,155,0.11), rgba(24,27,25,0.92) 58%);
2868
- border-color: rgba(65,211,155,0.24);
2883
+ background: linear-gradient(135deg, rgba(167,124,255,0.16), rgba(34,36,49,0.9) 58%);
2884
+ border-color: rgba(167,124,255,0.28);
2869
2885
  }
2870
2886
 
2871
2887
  .app-layout .ops-card.interactive:hover {
2872
- background: linear-gradient(135deg, rgba(65,211,155,0.13), rgba(24,27,25,0.96) 62%);
2873
- border-color: rgba(65,211,155,0.34);
2874
- box-shadow: var(--shadow), 0 0 24px rgba(65,211,155,0.1);
2888
+ background: linear-gradient(135deg, rgba(167,124,255,0.18), rgba(34,36,49,0.96) 62%);
2889
+ border-color: rgba(167,124,255,0.38);
2890
+ box-shadow: var(--shadow), 0 0 26px rgba(167,124,255,0.13);
2875
2891
  }
2876
2892
 
2877
2893
  .app-layout .ops-card.primary .ops-icon,
2878
2894
  .app-layout .empty-icon,
2879
2895
  .app-layout .empty-state > div[style] {
2880
- background: rgba(65,211,155,0.1) !important;
2881
- border-color: rgba(65,211,155,0.2) !important;
2896
+ background: rgba(167,124,255,0.12) !important;
2897
+ border-color: rgba(167,124,255,0.24) !important;
2882
2898
  color: var(--accent) !important;
2883
2899
  box-shadow: none !important;
2884
2900
  }
2885
2901
 
2886
2902
  .app-layout .ops-card:not(.primary) .ops-icon {
2887
- background: rgba(243,241,232,0.045);
2888
- border-color: rgba(243,241,232,0.08);
2903
+ background: rgba(247,247,242,0.045);
2904
+ border-color: rgba(218,225,255,0.1);
2889
2905
  color: var(--accent-3);
2890
2906
  }
2891
2907
 
@@ -2896,28 +2912,28 @@
2896
2912
  }
2897
2913
 
2898
2914
  .app-layout .empty-chip {
2899
- background: rgba(24,27,25,0.82);
2900
- border-color: rgba(243,241,232,0.1);
2915
+ background: rgba(34,36,49,0.82);
2916
+ border-color: rgba(218,225,255,0.12);
2901
2917
  color: var(--muted);
2902
2918
  box-shadow: none;
2903
2919
  }
2904
2920
 
2905
2921
  .app-layout .empty-chip:hover {
2906
- background: rgba(65,211,155,0.08);
2907
- border-color: rgba(65,211,155,0.26);
2922
+ background: rgba(167,124,255,0.10);
2923
+ border-color: rgba(167,124,255,0.28);
2908
2924
  box-shadow: 0 10px 28px rgba(0,0,0,0.2);
2909
2925
  }
2910
2926
 
2911
2927
  .app-layout .user .bubble {
2912
- background: #2b604c;
2913
- border-color: rgba(65,211,155,0.22);
2914
- color: #f5fff9;
2928
+ background: linear-gradient(135deg, rgba(98,89,196,0.96), rgba(45,116,156,0.92));
2929
+ border-color: rgba(167,124,255,0.24);
2930
+ color: #fbfbff;
2915
2931
  box-shadow: 0 8px 24px rgba(0,0,0,0.22);
2916
2932
  }
2917
2933
 
2918
2934
  .app-layout .ai .bubble {
2919
- background: rgba(27, 30, 28, 0.94);
2920
- border-color: rgba(243,241,232,0.11);
2935
+ background: rgba(34, 36, 49, 0.9);
2936
+ border-color: rgba(218,225,255,0.13);
2921
2937
  box-shadow: 0 8px 24px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.035);
2922
2938
  }
2923
2939
 
@@ -2930,45 +2946,45 @@
2930
2946
 
2931
2947
  .app-layout .code-container,
2932
2948
  .app-layout pre {
2933
- background: #0d0f0d;
2934
- border-color: rgba(243,241,232,0.08);
2949
+ background: #171925;
2950
+ border-color: rgba(218,225,255,0.10);
2935
2951
  }
2936
2952
 
2937
2953
  .app-layout .code-header {
2938
- background: #121411;
2939
- border-bottom-color: rgba(243,241,232,0.07);
2954
+ background: #1d2030;
2955
+ border-bottom-color: rgba(218,225,255,0.09);
2940
2956
  }
2941
2957
 
2942
2958
  .app-layout .input-area {
2943
- background: linear-gradient(0deg, rgba(17,19,18,0.98) 0%, rgba(17,19,18,0.78) 64%, transparent 100%);
2959
+ background: linear-gradient(0deg, rgba(39,41,55,0.98) 0%, rgba(39,41,55,0.78) 64%, transparent 100%);
2944
2960
  }
2945
2961
 
2946
2962
  .app-layout .input-box {
2947
- background: rgba(24, 27, 25, 0.96);
2948
- border-color: rgba(243,241,232,0.12);
2963
+ background: rgba(34, 36, 49, 0.94);
2964
+ border-color: rgba(218,225,255,0.15);
2949
2965
  box-shadow: var(--shadow), inset 0 1px 0 rgba(255,255,255,0.035);
2950
2966
  }
2951
2967
 
2952
2968
  .app-layout .input-box:focus-within {
2953
- border-color: rgba(65,211,155,0.36);
2954
- box-shadow: var(--shadow), 0 0 0 3px rgba(65,211,155,0.06);
2969
+ border-color: rgba(167,124,255,0.42);
2970
+ box-shadow: var(--shadow), 0 0 0 3px rgba(167,124,255,0.08);
2955
2971
  }
2956
2972
 
2957
2973
  .app-layout .action-btn:hover,
2958
2974
  .app-layout .file-type-btn:hover {
2959
- background: rgba(65,211,155,0.08);
2975
+ background: rgba(167,124,255,0.10);
2960
2976
  color: var(--accent);
2961
- border-color: rgba(65,211,155,0.24);
2977
+ border-color: rgba(167,124,255,0.28);
2962
2978
  }
2963
2979
 
2964
2980
  .app-layout .send-btn {
2965
- background: #41d39b;
2966
- color: #0b120e;
2967
- box-shadow: 0 8px 20px rgba(0,0,0,0.24);
2981
+ background: #f7f7f2;
2982
+ color: #242632;
2983
+ box-shadow: 0 8px 20px rgba(0,0,0,0.24), 0 0 22px rgba(247,247,242,0.12);
2968
2984
  }
2969
2985
 
2970
2986
  .app-layout .send-btn:hover {
2971
- background: #5ee2ae;
2987
+ background: #ffffff;
2972
2988
  box-shadow: 0 10px 24px rgba(0,0,0,0.28);
2973
2989
  }
2974
2990
  </style>
@@ -3864,22 +3880,26 @@
3864
3880
 
3865
3881
  function modelOptionHtml(model, group, engine) {
3866
3882
  const engineMissing = engine && !engine.installed;
3883
+ const isLocalEngine = engine && (engine.kind === 'local' || engine.kind === 'local-server');
3884
+ const unsupported = engine?.supported === false;
3867
3885
  const keyMissing = model.available === false;
3868
3886
  const verifyFailed = engine?.kind === 'cloud' && model.verified === false;
3869
3887
  const verifyUnknown = engine?.kind === 'cloud' && model.verified == null;
3870
3888
  const supportsPull = model.pullable === true;
3871
3889
  const needsPull = supportsPull && engine.installed && model.pulled === false;
3872
- const isUnavailable = engineMissing || keyMissing || verifyFailed;
3873
- const badge = engineMissing ? '엔진 설치 필요'
3874
- : needsPull ? '클릭하여 다운로드'
3890
+ const isUnavailable = unsupported || (!isLocalEngine && engineMissing) || keyMissing || verifyFailed;
3891
+ const badge = unsupported ? '현재 환경 미지원'
3892
+ : engineMissing && isLocalEngine ? '설치 후 자동 로드'
3893
+ : engineMissing ? '엔진 설치 필요'
3894
+ : needsPull ? '다운로드 후 자동 로드'
3875
3895
  : keyMissing ? `필요: ${model.requires || 'API key'}`
3876
3896
  : verifyFailed ? `실패: ${model.verify_reason || '검증 실패'}`
3877
3897
  : verifyUnknown ? '실사용 테스트 필요'
3878
3898
  : (model.tag || group);
3879
- const icon = isUnavailable ? 'ti-lock' : needsPull ? 'ti-cloud-download' : verifyUnknown ? 'ti-activity' : 'ti-switch-3';
3880
- const cls = needsPull ? ' needs-pull' : '';
3881
- const action = needsPull
3882
- ? `pullAndLoadModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
3899
+ const icon = isUnavailable ? 'ti-lock' : (engineMissing || needsPull) ? 'ti-cloud-download' : verifyUnknown ? 'ti-activity' : 'ti-switch-3';
3900
+ const cls = (engineMissing || needsPull) && isLocalEngine ? ' needs-pull' : '';
3901
+ const action = isLocalEngine
3902
+ ? `prepareAndLoadModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
3883
3903
  : `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
3884
3904
  return `
3885
3905
  <button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
@@ -3939,9 +3959,11 @@
3939
3959
  }
3940
3960
 
3941
3961
  function engineCardHtml(engine) {
3942
- const state = engine.installed ? '설치됨' : '설치 필요';
3962
+ const waitingForServer = engine.id === 'lmstudio' && engine.installed && engine.server_ready === false;
3963
+ const unsupported = engine.supported === false;
3964
+ const state = unsupported ? '미지원' : waitingForServer ? '서버 대기' : engine.installed ? '설치됨' : '설치 필요';
3943
3965
  const needsApiKey = engine.kind === 'cloud' && !!engine.requires;
3944
- const installButton = !needsApiKey && engine.installable && !engine.installed
3966
+ const installButton = !unsupported && !needsApiKey && engine.installable && !engine.installed
3945
3967
  ? `<button class="admin-action" onclick="installEngine('${engine.id}')"><i class="ti ti-download"></i> 설치</button>`
3946
3968
  : '';
3947
3969
  const requirement = needsApiKey
@@ -3964,7 +3986,7 @@
3964
3986
  ${engine.note ? `<span>${escapeHtml(engine.note)}</span>` : ''}
3965
3987
  </div>
3966
3988
  <div class="engine-actions">
3967
- <span class="engine-state ${engine.installed ? 'ready' : ''}">${state}</span>
3989
+ <span class="engine-state ${engine.installed && !waitingForServer && !unsupported ? 'ready' : ''}">${state}</span>
3968
3990
  ${installButton}
3969
3991
  </div>
3970
3992
  </div>
@@ -4109,19 +4131,21 @@
4109
4131
  }
4110
4132
  }
4111
4133
 
4112
- async function pullAndLoadModel(encodedId, engine = '') {
4134
+ async function prepareAndLoadModel(encodedId, engine = '') {
4113
4135
  const modelId = decodeURIComponent(encodedId);
4114
4136
  const displayName = compactModelName(modelId);
4115
- document.getElementById('model-list').innerHTML = `<div class="sensitivity-preview"><b>${escapeHtml(displayName)}</b> 다운로드 중입니다... (네트워크 속도에 따라 수 분이 걸릴 수 있습니다)</div>`;
4137
+ document.getElementById('model-list').innerHTML = `<div class="sensitivity-preview"><b>${escapeHtml(displayName)}</b> 준비 중입니다...<br>엔진 설치, 모델 다운로드, 서버 시작, 로드까지 자동으로 진행합니다. 첫 실행은 수 분이 걸릴 수 있습니다.</div>`;
4116
4138
  try {
4117
- const pullRes = await apiFetch('/engines/pull-model', {
4139
+ const res = await apiFetch('/engines/prepare-model', {
4118
4140
  method: 'POST',
4119
4141
  headers: { 'Content-Type': 'application/json' },
4120
- body: JSON.stringify({ model: modelId })
4142
+ body: JSON.stringify({ model: modelId, engine, user_email: currentUserEmail || null })
4121
4143
  });
4122
- const pullData = await pullRes.json();
4123
- if (!pullRes.ok) throw new Error(pullData.detail || 'pull 실패');
4124
- await loadSelectedModel(encodedId, engine);
4144
+ const data = await res.json();
4145
+ if (!res.ok) throw new Error(data.detail || '모델 준비에 실패했습니다.');
4146
+ closeModelPanel();
4147
+ await loadModelStatus();
4148
+ addMessage('ai', `<b>${escapeHtml(compactModelName(data.current || modelId))}</b> 로드 되었습니다.`);
4125
4149
  } catch (e) {
4126
4150
  document.getElementById('model-list').innerHTML = `
4127
4151
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
@@ -4130,6 +4154,10 @@
4130
4154
  }
4131
4155
  }
4132
4156
 
4157
+ async function pullAndLoadModel(encodedId, engine = '') {
4158
+ return prepareAndLoadModel(encodedId, engine);
4159
+ }
4160
+
4133
4161
  function fillVpcForm(config) {
4134
4162
  if (!config) return;
4135
4163
  document.getElementById('vpc-provider').value = config.provider || '';